linkedin-member-data 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +186 -0
- data/doc/CHANGELOG.md +9 -0
- data/doc/LinkedIn/MemberData/ApiError.md +52 -0
- data/doc/LinkedIn/MemberData/Authorization.md +30 -0
- data/doc/LinkedIn/MemberData/Changelog/Page.md +28 -0
- data/doc/LinkedIn/MemberData/Changelog.md +96 -0
- data/doc/LinkedIn/MemberData/Client.md +97 -0
- data/doc/LinkedIn/MemberData/ConfigurationError.md +8 -0
- data/doc/LinkedIn/MemberData/ConnectionError.md +15 -0
- data/doc/LinkedIn/MemberData/Domains.md +22 -0
- data/doc/LinkedIn/MemberData/Error.md +8 -0
- data/doc/LinkedIn/MemberData/Event.md +90 -0
- data/doc/LinkedIn/MemberData/Forbidden.md +8 -0
- data/doc/LinkedIn/MemberData/NotFound.md +8 -0
- data/doc/LinkedIn/MemberData/RateLimited.md +25 -0
- data/doc/LinkedIn/MemberData/ServerError.md +12 -0
- data/doc/LinkedIn/MemberData/Snapshot/Page.md +43 -0
- data/doc/LinkedIn/MemberData/Snapshot.md +90 -0
- data/doc/LinkedIn/MemberData/Unauthorized.md +8 -0
- data/doc/LinkedIn/MemberData/VersionError.md +8 -0
- data/doc/LinkedIn/MemberData.md +44 -0
- data/doc/LinkedIn.md +7 -0
- data/doc/README.md +186 -0
- data/exe/linkedin-member-data +6 -0
- data/lib/linkedin/member_data/authorization.rb +30 -0
- data/lib/linkedin/member_data/changelog/page.rb +28 -0
- data/lib/linkedin/member_data/changelog.rb +146 -0
- data/lib/linkedin/member_data/cli/changelog_command.rb +27 -0
- data/lib/linkedin/member_data/cli/command.rb +59 -0
- data/lib/linkedin/member_data/cli/global_options.rb +27 -0
- data/lib/linkedin/member_data/cli/simple_commands.rb +48 -0
- data/lib/linkedin/member_data/cli/since_parser.rb +36 -0
- data/lib/linkedin/member_data/cli/snapshot_command.rb +85 -0
- data/lib/linkedin/member_data/cli/support.rb +89 -0
- data/lib/linkedin/member_data/cli.rb +115 -0
- data/lib/linkedin/member_data/client.rb +107 -0
- data/lib/linkedin/member_data/connection.rb +165 -0
- data/lib/linkedin/member_data/domains.rb +43 -0
- data/lib/linkedin/member_data/errors.rb +154 -0
- data/lib/linkedin/member_data/event.rb +101 -0
- data/lib/linkedin/member_data/snapshot/page.rb +53 -0
- data/lib/linkedin/member_data/snapshot.rb +114 -0
- data/lib/linkedin/member_data/util.rb +52 -0
- data/lib/linkedin/member_data/version.rb +16 -0
- data/lib/linkedin/member_data.rb +12 -0
- data/llms.txt +44 -0
- metadata +95 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
# Entry point of the gem. Holds one `Connection`.
|
|
6
|
+
#
|
|
7
|
+
# @example Create a client and read a snapshot
|
|
8
|
+
# client = LinkedIn::MemberData::Client.new(access_token: ENV["LINKEDIN_ACCESS_TOKEN"])
|
|
9
|
+
# client.snapshot(:connections).each { |row| puts row["First Name"] }
|
|
10
|
+
class Client
|
|
11
|
+
# API path of the member authorizations resource.
|
|
12
|
+
# @return [String]
|
|
13
|
+
AUTHORIZATIONS_PATH = "/rest/memberAuthorizations"
|
|
14
|
+
|
|
15
|
+
# The HTTP connection used for every request.
|
|
16
|
+
# @api private
|
|
17
|
+
# @return [Connection]
|
|
18
|
+
attr_reader :connection
|
|
19
|
+
|
|
20
|
+
# Builds a client. No request is sent.
|
|
21
|
+
#
|
|
22
|
+
# @param access_token [String] OAuth token with scope `r_dma_portability_self_serve`.
|
|
23
|
+
# Leading and trailing spaces are removed.
|
|
24
|
+
# @param retries [Integer] retries on 429, 5xx and network errors, with backoff. `0` turns retries off.
|
|
25
|
+
# @param timeout [Integer, Float] open, read and write timeout in seconds.
|
|
26
|
+
# @param logger [Logger, nil] gets one debug line per request: `GET url -> status`.
|
|
27
|
+
# @param connection [Connection, nil] ready-made connection. When given, `retries`, `timeout`
|
|
28
|
+
# and `logger` are not used. Meant for tests.
|
|
29
|
+
# @raise [ConfigurationError] when `access_token` is nil or blank.
|
|
30
|
+
def initialize(access_token:, retries: 3, timeout: 30, logger: nil, connection: nil)
|
|
31
|
+
ensure_token(access_token)
|
|
32
|
+
@connection = connection_for(connection, access_token: access_token.to_s.strip, retries:, timeout:, logger:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns a lazy view over snapshot data. No request is sent until you iterate.
|
|
36
|
+
#
|
|
37
|
+
# @example One domain, symbol or string
|
|
38
|
+
# client.snapshot(:profile).first # symbol is upcased: "PROFILE"
|
|
39
|
+
# client.snapshot("ALL_COMMENTS").to_a
|
|
40
|
+
# @example All domains
|
|
41
|
+
# client.snapshot.each { |row| puts row.keys.inspect }
|
|
42
|
+
# @param domain [Symbol, String, nil] a name from `Domains::ALL`. A Symbol is upcased.
|
|
43
|
+
# A String is sent as given, because the API is case sensitive. `nil` means all domains.
|
|
44
|
+
# @return [Snapshot]
|
|
45
|
+
# @raise [ArgumentError] when `domain` is not a Symbol, a String or nil.
|
|
46
|
+
def snapshot(domain = nil)
|
|
47
|
+
Snapshot.new(connection, Domains.normalize(domain))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns a lazy view over the changelog of the last 28 days. No request is sent until you iterate.
|
|
51
|
+
#
|
|
52
|
+
# @example Events of the last 7 days
|
|
53
|
+
# client.changelog(since: Time.now - 7 * 86_400).each do |event|
|
|
54
|
+
# puts "#{event.method} #{event.resource_name} at #{event.processed_at}"
|
|
55
|
+
# end
|
|
56
|
+
# @example Resume from the last event you saw
|
|
57
|
+
# last_seen = client.changelog.to_a.last&.processed_at_ms
|
|
58
|
+
# client.changelog(since: last_seen).each { |event| puts event.id }
|
|
59
|
+
# @param since [Time, Date, Integer, nil] first `processedAt` to fetch.
|
|
60
|
+
# Integer is epoch milliseconds. A Date is midnight UTC. `nil` starts at the oldest event.
|
|
61
|
+
# @param count [Integer] events per request, from 1 to 50.
|
|
62
|
+
# @return [Changelog]
|
|
63
|
+
# @raise [ArgumentError] when `count` is outside 1..50 or `since` has an unsupported type.
|
|
64
|
+
# Raised before any request.
|
|
65
|
+
def changelog(since: nil, count: Changelog::DEFAULT_COUNT)
|
|
66
|
+
Changelog.new(connection, since: since, count: count)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fetches the authorization record of the token. Sends one request.
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# authorization = client.authorization
|
|
73
|
+
# puts authorization.regulated_at if authorization
|
|
74
|
+
# @return [Authorization, nil] `nil` when LinkedIn returns no authorization.
|
|
75
|
+
# @raise [ApiError] on a non-2xx response, for example `Unauthorized` for a bad token.
|
|
76
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
77
|
+
def authorization
|
|
78
|
+
element = member_authorizations.first
|
|
79
|
+
Authorization.from_api(element) unless element.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Starts changelog archiving for the member. LinkedIn usually does this by itself.
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# client.enable_changelog! # => true
|
|
86
|
+
# @return [true] always true. API failures raise.
|
|
87
|
+
# @raise [ApiError] on a non-2xx response.
|
|
88
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
89
|
+
def enable_changelog!
|
|
90
|
+
connection.post(AUTHORIZATIONS_PATH, {})
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def member_authorizations
|
|
97
|
+
connection.get(AUTHORIZATIONS_PATH, q: "memberAndApplication").fetch("elements", [])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def connection_for(connection, **) = connection.nil? ? Connection.new(**) : connection
|
|
101
|
+
|
|
102
|
+
def ensure_token(token)
|
|
103
|
+
raise ConfigurationError, "access_token is required" if token.to_s.strip.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module LinkedIn
|
|
9
|
+
module MemberData
|
|
10
|
+
# One HTTP door to api.linkedin.com. Adds headers, parses JSON,
|
|
11
|
+
# maps statuses to errors and retries 429/5xx/network failures.
|
|
12
|
+
# @api private
|
|
13
|
+
class Connection
|
|
14
|
+
# @return [String]
|
|
15
|
+
BASE_URL = "https://api.linkedin.com"
|
|
16
|
+
# Value of the `Linkedin-Version` header.
|
|
17
|
+
# @return [String]
|
|
18
|
+
API_VERSION = "202312"
|
|
19
|
+
# Network errors that become a `ConnectionError`.
|
|
20
|
+
# @return [Array<Class>]
|
|
21
|
+
RETRYABLE_EXCEPTIONS = [
|
|
22
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
23
|
+
Errno::EPIPE, SocketError, EOFError, OpenSSL::SSL::SSLError
|
|
24
|
+
].freeze
|
|
25
|
+
# Upper limit for a server-sent Retry-After, in seconds.
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
MAX_RETRY_AFTER = 60
|
|
28
|
+
HEADERS = {
|
|
29
|
+
"Linkedin-Version" => API_VERSION,
|
|
30
|
+
"X-Restli-Protocol-Version" => "2.0.0",
|
|
31
|
+
"Content-Type" => "application/json",
|
|
32
|
+
"User-Agent" => "linkedin-member-data/#{VERSION}"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Default transport: a real Net::HTTP call. Replaced in tests.
|
|
36
|
+
# @api private
|
|
37
|
+
class NetHttpTransport
|
|
38
|
+
# @param timeout [Integer, Float] open, read and write timeout in seconds.
|
|
39
|
+
def initialize(timeout:)
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param request [Net::HTTPRequest]
|
|
44
|
+
# @param uri [URI::HTTPS]
|
|
45
|
+
# @return [Net::HTTPResponse]
|
|
46
|
+
def call(request, uri)
|
|
47
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: @timeout,
|
|
48
|
+
read_timeout: @timeout, write_timeout: @timeout) do |http|
|
|
49
|
+
http.request(request)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Runs a block again on retryable errors. Waits for Retry-After, else backs off.
|
|
55
|
+
# @api private
|
|
56
|
+
class Retrier
|
|
57
|
+
def initialize(retries:, sleeper:)
|
|
58
|
+
@retries = retries
|
|
59
|
+
@sleeper = sleeper
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Yields, and runs again on a retryable error. Raises after the last try.
|
|
63
|
+
# @api private
|
|
64
|
+
def run
|
|
65
|
+
(0..@retries).each do |attempt|
|
|
66
|
+
return yield
|
|
67
|
+
rescue ApiError, ConnectionError => error
|
|
68
|
+
wait_or_raise(error, attempt)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def wait_or_raise(error, attempt)
|
|
75
|
+
raise error unless error.retryable? && attempt < @retries
|
|
76
|
+
|
|
77
|
+
backoff = (0.5 * (2**attempt)) + (rand * 0.1)
|
|
78
|
+
# compact.first picks Retry-After when present (a plain `||` is not provable by MC/DC).
|
|
79
|
+
@sleeper.call([[error.retry_after, backoff].compact.first, MAX_RETRY_AFTER].min)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_constant :Retrier, :HEADERS
|
|
83
|
+
|
|
84
|
+
# @param access_token [String] OAuth token. Sent as a Bearer header.
|
|
85
|
+
# @param retries [Integer] retries on 429, 5xx and network errors. `0` turns retries off.
|
|
86
|
+
# @param timeout [Integer, Float] timeout in seconds for the default transport.
|
|
87
|
+
# @param logger [Logger, nil] gets one debug line per request.
|
|
88
|
+
# @param sleeper [#call] called with the seconds to wait between retries.
|
|
89
|
+
# @param transport [#call] takes `(request, uri)` and returns a response. Replaced in tests.
|
|
90
|
+
def initialize(access_token:, retries: 3, timeout: 30, logger: nil, sleeper: Kernel.method(:sleep),
|
|
91
|
+
transport: NetHttpTransport.new(timeout: timeout))
|
|
92
|
+
@headers = HEADERS.merge("Authorization" => "Bearer #{access_token}")
|
|
93
|
+
@retrier = Retrier.new(retries: retries, sleeper: sleeper)
|
|
94
|
+
@logger = logger
|
|
95
|
+
@transport = transport
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Hides the token.
|
|
99
|
+
# @return [String]
|
|
100
|
+
def inspect = "#<#{self.class} base_url=#{BASE_URL}>"
|
|
101
|
+
|
|
102
|
+
# Sends a GET request.
|
|
103
|
+
#
|
|
104
|
+
# @param path [String] must start with a single `/`.
|
|
105
|
+
# @param params [Hash] query parameters. Nil values are dropped.
|
|
106
|
+
# @return [Hash] parsed JSON body. `{}` for an empty body.
|
|
107
|
+
# @raise [ArgumentError] when `path` does not start with a single `/`.
|
|
108
|
+
# @raise [ApiError] on a non-2xx response, or when a 2xx body is not JSON.
|
|
109
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
110
|
+
def get(path, params = {})
|
|
111
|
+
perform(build_request(Net::HTTP::Get, path, params))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Sends a POST request with a JSON body.
|
|
115
|
+
#
|
|
116
|
+
# @param path [String] must start with a single `/`.
|
|
117
|
+
# @param body [Hash] sent as JSON.
|
|
118
|
+
# @return [Hash] parsed JSON body. `{}` for an empty body.
|
|
119
|
+
# @raise [ArgumentError] when `path` does not start with a single `/`.
|
|
120
|
+
# @raise [ApiError] on a non-2xx response, or when a 2xx body is not JSON.
|
|
121
|
+
# @raise [ConnectionError] on a network failure after all retries.
|
|
122
|
+
def post(path, body = {})
|
|
123
|
+
request = build_request(Net::HTTP::Post, path)
|
|
124
|
+
request.body = JSON.generate(body)
|
|
125
|
+
perform(request)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def build_request(klass, path, params = {})
|
|
131
|
+
raise ArgumentError, "path must start with a single /: #{path.inspect}" unless path.match?(%r{\A/(?!/)})
|
|
132
|
+
|
|
133
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
134
|
+
query = params.compact
|
|
135
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
136
|
+
klass.new(uri, @headers)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def perform(request)
|
|
140
|
+
@retrier.run { attempt_request(request) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def attempt_request(request)
|
|
144
|
+
response = transport_call(request)
|
|
145
|
+
code = response.code
|
|
146
|
+
@logger&.debug("#{request.method} #{request.uri} -> #{code}")
|
|
147
|
+
raise ApiError.from_response(response) unless code.start_with?("2")
|
|
148
|
+
|
|
149
|
+
parse_json(response.body.to_s, code.to_i)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def transport_call(request)
|
|
153
|
+
@transport.call(request, request.uri)
|
|
154
|
+
rescue *RETRYABLE_EXCEPTIONS => error
|
|
155
|
+
raise ConnectionError, "#{error.class}: #{error.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_json(text, status)
|
|
159
|
+
text.strip.empty? ? {} : JSON.parse(text)
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
raise ApiError.new("invalid JSON in response body", status: status)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
# Snapshot domains documented at
|
|
6
|
+
# https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/shared/snapshot-domain
|
|
7
|
+
# The API is case sensitive. Strings are sent as given; symbols are upcased.
|
|
8
|
+
module Domains
|
|
9
|
+
# Names of all snapshot domains. Pass one to `Client#snapshot`.
|
|
10
|
+
# @return [Array<String>]
|
|
11
|
+
ALL = %w[
|
|
12
|
+
ADS_CLICKED MEMBER_FOLLOWING LOGIN RICH_MEDIA SEARCHES INFERENCE_TAKEOUT
|
|
13
|
+
ALL_COMMENTS CONTACTS EVENTS RECEIPTS AD_TARGETING REGISTRATION REVIEWS
|
|
14
|
+
ARTICLES PATENTS GROUPS COMPANY_FOLLOWS INVITATIONS PHONE_NUMBERS
|
|
15
|
+
CONNECTIONS EMAIL_ADDRESSES JOB_POSTINGS JOB_APPLICATIONS
|
|
16
|
+
JOB_SEEKER_PREFERENCES LEARNING INBOX SAVED_JOBS SAVED_JOB_ALERTS PROFILE
|
|
17
|
+
SKILLS POSITIONS EDUCATION TEST_SCORES CAUSES_YOU_CARE_ABOUT PUBLICATIONS
|
|
18
|
+
PROJECTS ORGANIZATIONS LANGUAGES HONORS COURSES CERTIFICATIONS
|
|
19
|
+
RECOMMENDATIONS ENDORSEMENTS MEMBER_SHARE_INFO SECURITY_CHALLENGE_PIPE
|
|
20
|
+
TRUSTED_GRAPH MARKETPLACE_ENGAGEMENTS MARKETPLACE_PROVIDERS
|
|
21
|
+
MARKETPLACE_OPPORTUNITIES ACTOR_SAVE_ITEM JOB_APPLICANT_SAVED_ANSWERS
|
|
22
|
+
TALENT_QUESTION_SAVED_RESPONSE PROFILE_SUMMARY ALL_LIKES ALL_VOTES
|
|
23
|
+
RECEIPTS_LBP EASYAPPLY_BLOCKING LEARNING_COACH_AI_TAKEOUT
|
|
24
|
+
LEARNING_COACH_INBOX LEARNING_ROLEPLAY_INBOX VOLUNTEERING_EXPERIENCES
|
|
25
|
+
ACCOUNT_HISTORY INSTANT_REPOSTS IDENTITY_CREDENTIALS_AND_ASSETS ADS_LAN
|
|
26
|
+
PREMIUM_NOTES
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Turns a domain argument into the name sent to the API.
|
|
30
|
+
#
|
|
31
|
+
# @param domain [Symbol, String, nil] a Symbol is upcased. A String is returned as given. `nil` stays `nil`.
|
|
32
|
+
# @return [String, nil]
|
|
33
|
+
# @raise [ArgumentError] when `domain` is not a Symbol, a String or nil.
|
|
34
|
+
def self.normalize(domain)
|
|
35
|
+
case domain
|
|
36
|
+
when Symbol then domain.to_s.upcase
|
|
37
|
+
when nil, String then domain
|
|
38
|
+
else raise ArgumentError, "domain must be a Symbol or String, got #{domain.class}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LinkedIn
|
|
6
|
+
module MemberData
|
|
7
|
+
# Base class of every error this gem raises on purpose.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Missing or empty access token.
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
# Network failure after all retries (timeouts, reset connections).
|
|
14
|
+
class ConnectionError < Error
|
|
15
|
+
# @return [true] network errors are worth a retry.
|
|
16
|
+
def retryable? = true
|
|
17
|
+
|
|
18
|
+
# @return [nil] there is no Retry-After header, so the backoff decides.
|
|
19
|
+
def retry_after = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Any non-2xx HTTP response, or a 2xx response with a body that is not JSON.
|
|
23
|
+
# The class depends on the status. See `Unauthorized`, `Forbidden`, `NotFound`,
|
|
24
|
+
# `VersionError`, `RateLimited` and `ServerError`. Other statuses raise ApiError itself.
|
|
25
|
+
class ApiError < Error
|
|
26
|
+
# Details of the failed response.
|
|
27
|
+
# `status` is the HTTP status code (Integer).
|
|
28
|
+
# `code` is `serviceErrorCode` or `code` from the body (Integer or String, nil when the body has neither).
|
|
29
|
+
# `body` is the parsed JSON body (nil when the body is empty, not JSON, or not an object).
|
|
30
|
+
# @return [Integer, String, Hash, nil] `status`, `code` or `body`.
|
|
31
|
+
attr_reader :status, :code, :body
|
|
32
|
+
|
|
33
|
+
# Builds the error class that fits the response status.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
# @param response [Net::HTTPResponse] a non-2xx response.
|
|
37
|
+
# @return [ApiError] an instance of the subclass for the status.
|
|
38
|
+
def self.from_response(response)
|
|
39
|
+
status = response.code.to_i
|
|
40
|
+
body = parse_body(response.body)
|
|
41
|
+
klass = class_for(status)
|
|
42
|
+
klass.new(message_from(body, status), status: status, code: code_from(body), body: body,
|
|
43
|
+
**klass.extra_options(response))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.class_for(status)
|
|
47
|
+
STATUS_CLASSES.fetch(status) { status >= 500 ? ServerError : ApiError }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.parse_body(raw)
|
|
51
|
+
return nil if raw.to_s.empty?
|
|
52
|
+
|
|
53
|
+
parsed = JSON.parse(raw)
|
|
54
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.message_from(body, status)
|
|
60
|
+
message = body.to_h["message"]
|
|
61
|
+
message.is_a?(String) && !message.empty? ? message : "HTTP #{status}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.code_from(body) = body && (body["serviceErrorCode"] || body["code"])
|
|
65
|
+
|
|
66
|
+
# Internal hook: subclasses add constructor options read from the response.
|
|
67
|
+
# @api private
|
|
68
|
+
def self.extra_options(_response) = {}
|
|
69
|
+
|
|
70
|
+
private_class_method :class_for, :parse_body, :message_from, :code_from
|
|
71
|
+
|
|
72
|
+
# @param message [String] `message` from the body, or "HTTP <status>".
|
|
73
|
+
# @param status [Integer] HTTP status code.
|
|
74
|
+
# @param code [Integer, String, nil] error code from the body.
|
|
75
|
+
# @param body [Hash, nil] parsed response body.
|
|
76
|
+
def initialize(message, status:, code: nil, body: nil)
|
|
77
|
+
super(message)
|
|
78
|
+
@status = status
|
|
79
|
+
@code = code
|
|
80
|
+
@body = body
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Tells if a retry may help. False for most API errors.
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def retryable? = false
|
|
86
|
+
|
|
87
|
+
# Only RateLimited knows a Retry-After. Others let the backoff decide.
|
|
88
|
+
# @return [nil]
|
|
89
|
+
def retry_after = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# HTTP 401. The token is invalid or expired.
|
|
93
|
+
class Unauthorized < ApiError; end
|
|
94
|
+
|
|
95
|
+
# HTTP 403. The token is not allowed to read this data.
|
|
96
|
+
class Forbidden < ApiError; end
|
|
97
|
+
|
|
98
|
+
# HTTP 404. For snapshots it can mean "No data found for this memberId".
|
|
99
|
+
class NotFound < ApiError; end
|
|
100
|
+
|
|
101
|
+
# HTTP 426. The API version sent by the gem is not supported.
|
|
102
|
+
class VersionError < ApiError; end
|
|
103
|
+
|
|
104
|
+
# HTTP 429. Retryable.
|
|
105
|
+
class RateLimited < ApiError
|
|
106
|
+
# Seconds to wait, from the `Retry-After` header.
|
|
107
|
+
# @return [Integer, nil] `nil` when the header is absent, is not a positive number, or is an HTTP date.
|
|
108
|
+
attr_reader :retry_after
|
|
109
|
+
|
|
110
|
+
# Adds the parsed `Retry-After` header to the constructor options.
|
|
111
|
+
# @api private
|
|
112
|
+
def self.extra_options(response) = { retry_after: retry_after_from(response["Retry-After"]) }
|
|
113
|
+
|
|
114
|
+
# Only a positive number of seconds counts. HTTP-dates and 0 mean "use backoff".
|
|
115
|
+
# @api private
|
|
116
|
+
def self.retry_after_from(value)
|
|
117
|
+
seconds = value.to_s.match?(/\A\d+\z/) ? value.to_i : 0
|
|
118
|
+
seconds.positive? ? seconds : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param message [String] error message.
|
|
122
|
+
# @param status [Integer] HTTP status code.
|
|
123
|
+
# @param code [Integer, String, nil] error code from the body.
|
|
124
|
+
# @param body [Hash, nil] parsed response body.
|
|
125
|
+
# @param retry_after [Integer, nil] seconds from the `Retry-After` header.
|
|
126
|
+
def initialize(message, status:, code: nil, body: nil, retry_after: nil)
|
|
127
|
+
super(message, status: status, code: code, body: body)
|
|
128
|
+
@retry_after = retry_after
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [true] a rate limit is worth a retry.
|
|
132
|
+
def retryable? = true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# HTTP 5xx. Retryable.
|
|
136
|
+
class ServerError < ApiError
|
|
137
|
+
# @return [true] server errors are worth a retry.
|
|
138
|
+
def retryable? = true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Defined last because the classes must exist first.
|
|
142
|
+
class ApiError
|
|
143
|
+
# HTTP status => error class. Other statuses use `ServerError` (5xx) or `ApiError`.
|
|
144
|
+
# @api private
|
|
145
|
+
STATUS_CLASSES = {
|
|
146
|
+
401 => Unauthorized,
|
|
147
|
+
403 => Forbidden,
|
|
148
|
+
404 => NotFound,
|
|
149
|
+
426 => VersionError,
|
|
150
|
+
429 => RateLimited
|
|
151
|
+
}.freeze
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
# Data member name => JSON key. Time fields are handled separately.
|
|
6
|
+
# @api private
|
|
7
|
+
EVENT_KEYS = {
|
|
8
|
+
id: "id",
|
|
9
|
+
activity_id: "activityId",
|
|
10
|
+
activity_status: "activityStatus",
|
|
11
|
+
config_version: "configVersion",
|
|
12
|
+
owner: "owner",
|
|
13
|
+
actor: "actor",
|
|
14
|
+
resource_name: "resourceName",
|
|
15
|
+
resource_id: "resourceId",
|
|
16
|
+
resource_uri: "resourceUri",
|
|
17
|
+
# Shadows Object#method on purpose; it is the API field name.
|
|
18
|
+
method: "method",
|
|
19
|
+
method_name: "methodName",
|
|
20
|
+
activity: "activity",
|
|
21
|
+
processed_activity: "processedActivity",
|
|
22
|
+
sibling_activities: "siblingActivities",
|
|
23
|
+
parent_sibling_activities: "parentSiblingActivities"
|
|
24
|
+
}.freeze
|
|
25
|
+
private_constant :EVENT_KEYS
|
|
26
|
+
|
|
27
|
+
# One Member Changelog event. Immutable. Times are UTC. `activity` and friends stay raw.
|
|
28
|
+
# Fields missing in the API response are `nil`.
|
|
29
|
+
#
|
|
30
|
+
# `method` is the API field (`CREATE`, `UPDATE`, ...). It shadows Ruby's `Object#method` on purpose,
|
|
31
|
+
# so `event.method(:name)` does not work on an Event.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] id
|
|
34
|
+
# @return [String, nil] event id. Used to skip events seen on the previous page.
|
|
35
|
+
# @!attribute [r] activity_id
|
|
36
|
+
# @return [String, nil] value as sent by the API.
|
|
37
|
+
# @!attribute [r] activity_status
|
|
38
|
+
# @return [String, nil] value as sent by the API.
|
|
39
|
+
# @!attribute [r] config_version
|
|
40
|
+
# @return [Object, nil] value as sent by the API.
|
|
41
|
+
# @!attribute [r] owner
|
|
42
|
+
# @return [String, nil] owner of the event, as sent by the API.
|
|
43
|
+
# @!attribute [r] actor
|
|
44
|
+
# @return [String, nil] who did the action, as sent by the API.
|
|
45
|
+
# @!attribute [r] resource_name
|
|
46
|
+
# @return [String, nil] kind of resource that changed.
|
|
47
|
+
# @!attribute [r] resource_id
|
|
48
|
+
# @return [String, nil] value as sent by the API.
|
|
49
|
+
# @!attribute [r] resource_uri
|
|
50
|
+
# @return [String, nil] value as sent by the API.
|
|
51
|
+
# @!attribute [r] method
|
|
52
|
+
# @return [String, nil] API method, for example `CREATE` or `UPDATE`.
|
|
53
|
+
# @!attribute [r] method_name
|
|
54
|
+
# @return [String, nil] value as sent by the API.
|
|
55
|
+
# @!attribute [r] captured_at
|
|
56
|
+
# @return [Time, nil] when LinkedIn captured the event. UTC.
|
|
57
|
+
# @!attribute [r] processed_at
|
|
58
|
+
# @return [Time, nil] when LinkedIn processed the event. UTC. Used as the cursor.
|
|
59
|
+
# @!attribute [r] activity
|
|
60
|
+
# @return [Hash, nil] raw activity payload.
|
|
61
|
+
# @!attribute [r] processed_activity
|
|
62
|
+
# @return [Hash, nil] raw processed activity payload.
|
|
63
|
+
# @!attribute [r] sibling_activities
|
|
64
|
+
# @return [Array<Hash>, nil] raw sibling activities.
|
|
65
|
+
# @!attribute [r] parent_sibling_activities
|
|
66
|
+
# @return [Array<Hash>, nil] raw parent sibling activities.
|
|
67
|
+
# @!attribute [r] raw
|
|
68
|
+
# @return [Hash] the original event Hash from the API.
|
|
69
|
+
Event = Data.define(
|
|
70
|
+
*EVENT_KEYS.keys, :captured_at, :processed_at, :raw
|
|
71
|
+
) do
|
|
72
|
+
# Builds an event from one element of the API response.
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# event = LinkedIn::MemberData::Event.from_api(
|
|
76
|
+
# "id" => "1", "method" => "CREATE", "processedAt" => 1_788_000_000_000
|
|
77
|
+
# )
|
|
78
|
+
# event.method # => "CREATE"
|
|
79
|
+
# event.processed_at # => 2026-08-29 10:40:00 UTC
|
|
80
|
+
# event.processed_at_ms # => 1788000000000
|
|
81
|
+
# @param hash [Hash] one element of `elements` from the changelog response.
|
|
82
|
+
# @return [Event]
|
|
83
|
+
def self.from_api(hash)
|
|
84
|
+
attributes = EVENT_KEYS.transform_values { |key| hash[key] }
|
|
85
|
+
new(**attributes, **times_from(hash), raw: hash)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.times_from(hash)
|
|
89
|
+
{ captured_at: Util.time_from_ms(hash["capturedAt"]),
|
|
90
|
+
processed_at: Util.time_from_ms(hash["processedAt"]) }
|
|
91
|
+
end
|
|
92
|
+
private_class_method :times_from
|
|
93
|
+
|
|
94
|
+
# Raw epoch milliseconds, used as the next `startTime` cursor.
|
|
95
|
+
# Pass it as `since:` to `Client#changelog` to resume.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer, nil] `nil` when the event has no `processedAt`.
|
|
98
|
+
def processed_at_ms = raw["processedAt"]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinkedIn
|
|
4
|
+
module MemberData
|
|
5
|
+
class Snapshot
|
|
6
|
+
# One response page of snapshot data. Immutable.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] domain
|
|
9
|
+
# @return [String, nil] `snapshotDomain` of the first element. An all-domains call may return several.
|
|
10
|
+
# @!attribute [r] rows
|
|
11
|
+
# @return [Array<Hash{String => Object}>] rows of every element on this page. Keys differ per domain.
|
|
12
|
+
# @!attribute [r] start
|
|
13
|
+
# @return [Integer, nil] page index from the `paging` object.
|
|
14
|
+
# @!attribute [r] count
|
|
15
|
+
# @return [Integer, nil] page size from the `paging` object.
|
|
16
|
+
# @!attribute [r] total
|
|
17
|
+
# @return [Integer, nil] total number of items from the `paging` object.
|
|
18
|
+
# @!attribute [r] has_next
|
|
19
|
+
# @return [Boolean] true when `paging.links` has a link with rel `next`.
|
|
20
|
+
# @!attribute [r] raw
|
|
21
|
+
# @return [Hash] the parsed response body.
|
|
22
|
+
Page = Data.define(:domain, :rows, :start, :count, :total, :has_next, :raw) do
|
|
23
|
+
# Builds a page from a parsed response body. Missing keys give empty or nil values.
|
|
24
|
+
#
|
|
25
|
+
# @param raw [Hash] parsed JSON body of the API response.
|
|
26
|
+
# @return [Snapshot::Page]
|
|
27
|
+
def self.from_api(raw)
|
|
28
|
+
elements = raw.fetch("elements", [])
|
|
29
|
+
# domain comes from the first element; an all-domains call may return several
|
|
30
|
+
new(domain: elements.dig(0, "snapshotDomain"), rows: rows_from(elements), raw: raw,
|
|
31
|
+
**paging_from(raw.fetch("paging", {})))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.rows_from(elements)
|
|
35
|
+
elements.flat_map { |element| element.fetch("snapshotData", []) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.paging_from(paging)
|
|
39
|
+
{ start: paging["start"], count: paging["count"], total: paging["total"],
|
|
40
|
+
has_next: paging.fetch("links", []).any? { |link| link["rel"] == "next" } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :rows_from, :paging_from
|
|
44
|
+
|
|
45
|
+
# Tells if another page may follow. Same value as `#has_next`.
|
|
46
|
+
# The last page can still carry a `next` link, so an empty next page is possible.
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def next? = has_next
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|