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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +186 -0
  6. data/doc/CHANGELOG.md +9 -0
  7. data/doc/LinkedIn/MemberData/ApiError.md +52 -0
  8. data/doc/LinkedIn/MemberData/Authorization.md +30 -0
  9. data/doc/LinkedIn/MemberData/Changelog/Page.md +28 -0
  10. data/doc/LinkedIn/MemberData/Changelog.md +96 -0
  11. data/doc/LinkedIn/MemberData/Client.md +97 -0
  12. data/doc/LinkedIn/MemberData/ConfigurationError.md +8 -0
  13. data/doc/LinkedIn/MemberData/ConnectionError.md +15 -0
  14. data/doc/LinkedIn/MemberData/Domains.md +22 -0
  15. data/doc/LinkedIn/MemberData/Error.md +8 -0
  16. data/doc/LinkedIn/MemberData/Event.md +90 -0
  17. data/doc/LinkedIn/MemberData/Forbidden.md +8 -0
  18. data/doc/LinkedIn/MemberData/NotFound.md +8 -0
  19. data/doc/LinkedIn/MemberData/RateLimited.md +25 -0
  20. data/doc/LinkedIn/MemberData/ServerError.md +12 -0
  21. data/doc/LinkedIn/MemberData/Snapshot/Page.md +43 -0
  22. data/doc/LinkedIn/MemberData/Snapshot.md +90 -0
  23. data/doc/LinkedIn/MemberData/Unauthorized.md +8 -0
  24. data/doc/LinkedIn/MemberData/VersionError.md +8 -0
  25. data/doc/LinkedIn/MemberData.md +44 -0
  26. data/doc/LinkedIn.md +7 -0
  27. data/doc/README.md +186 -0
  28. data/exe/linkedin-member-data +6 -0
  29. data/lib/linkedin/member_data/authorization.rb +30 -0
  30. data/lib/linkedin/member_data/changelog/page.rb +28 -0
  31. data/lib/linkedin/member_data/changelog.rb +146 -0
  32. data/lib/linkedin/member_data/cli/changelog_command.rb +27 -0
  33. data/lib/linkedin/member_data/cli/command.rb +59 -0
  34. data/lib/linkedin/member_data/cli/global_options.rb +27 -0
  35. data/lib/linkedin/member_data/cli/simple_commands.rb +48 -0
  36. data/lib/linkedin/member_data/cli/since_parser.rb +36 -0
  37. data/lib/linkedin/member_data/cli/snapshot_command.rb +85 -0
  38. data/lib/linkedin/member_data/cli/support.rb +89 -0
  39. data/lib/linkedin/member_data/cli.rb +115 -0
  40. data/lib/linkedin/member_data/client.rb +107 -0
  41. data/lib/linkedin/member_data/connection.rb +165 -0
  42. data/lib/linkedin/member_data/domains.rb +43 -0
  43. data/lib/linkedin/member_data/errors.rb +154 -0
  44. data/lib/linkedin/member_data/event.rb +101 -0
  45. data/lib/linkedin/member_data/snapshot/page.rb +53 -0
  46. data/lib/linkedin/member_data/snapshot.rb +114 -0
  47. data/lib/linkedin/member_data/util.rb +52 -0
  48. data/lib/linkedin/member_data/version.rb +16 -0
  49. data/lib/linkedin/member_data.rb +12 -0
  50. data/llms.txt +44 -0
  51. 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