OCaml client for the LinkedIn Voyager API
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

linkedin: migrate Voyager API client to new Json API

- Json.of_string now returns [(_, Json.Error.t) result]; convert to the
plain-string [error] polymorphic variant at the API boundary in
[Api.decode]. [`Json_parse of string] stays in the public API — it's
what [pp_error] wants to print — but the structured [Json.Error.t] is
carried through until the boundary.
- Rename [_jsont] codec bindings to [_json] and update the docstring to
mention [json] (the library was renamed).
- [Json.to_string] now returns [string] directly — collapse the
[match … with Ok _ | Error _] round-trip scaffolding in the
[test_post] / [test_profile] [roundtrip] helpers.

+98 -94
-1
bin/main.ml
··· 103 103 104 104 let dim = Style.fg Color.bright_black 105 105 let bold_cyan = Style.(bold + fg Color.cyan) 106 - let bold_yellow = Style.(bold + fg Color.yellow) 107 106 let styled s txt = Span.to_string (Span.styled s txt) 108 107 109 108 (** {1 Display helpers} *)
+6 -5
lib/api.ml
··· 4 4 5 5 (** {1 JSON helpers} *) 6 6 7 - let decode codec s = Json_bytesrw.decode_string codec s 7 + let decode codec s = 8 + Result.map_error Json.Error.to_string (Json.of_string codec s) 8 9 9 10 (** {1 Error type} *) 10 11 ··· 118 119 119 120 (** {1 API endpoints} *) 120 121 121 - let me t = json t "/voyager/api/me" Profile.me_jsont 122 + let me t = json t "/voyager/api/me" Profile.me_json 122 123 123 124 let profile ~public_id t = 124 125 let path = 125 126 Fmt.str "/voyager/api/identity/profiles/%s/profileView" public_id 126 127 in 127 - json t path Profile.jsont 128 + json t path Profile.json 128 129 129 130 let posts ?(start = 0) ?(count = 10) ~profile_id t = 130 131 let path = ··· 132 133 "/voyager/api/feed/updates?profileId=%s&q=memberShareFeed&moduleKey=member-share&count=%d&start=%d" 133 134 profile_id count start 134 135 in 135 - json t path Post.feed_jsont 136 + json t path Post.feed_json 136 137 137 138 let url_encode s = 138 139 let buf = Buffer.create (String.length s * 3) in ··· 147 148 148 149 let post ~urn t = 149 150 let path = Fmt.str "/voyager/api/feed/updates/%s" (url_encode urn) in 150 - json t path Post.normalized_jsont 151 + json t path Post.normalized_json
+1 -1
lib/api.mli
··· 1 1 (** LinkedIn Voyager API. 2 2 3 3 This module provides access to LinkedIn's Voyager API using [requests] for 4 - HTTP and [jsont] for JSON encoding/decoding. 4 + HTTP and [json] for JSON encoding/decoding. 5 5 6 6 Authentication requires [li_at] and [JSESSIONID] cookies from a browser 7 7 session. *)
-1
lib/dune
··· 6 6 fmt 7 7 logs 8 8 json 9 - json.bytesrw 10 9 crypto 11 10 kdf.pbkdf 12 11 requests
+1 -1
lib/linkedin.mli
··· 1 1 (** LinkedIn API client for OCaml. 2 2 3 3 A typed OCaml binding for the LinkedIn Voyager API using [requests] for HTTP 4 - and [jsont] for JSON encoding/decoding. Authentication uses browser session 4 + and [json] for JSON encoding/decoding. Authentication uses browser session 5 5 cookies ([li_at] and [JSESSIONID]). *) 6 6 7 7 module Profile = Profile
+21 -17
lib/post.ml
··· 29 29 ?(num_comments = 0) () = 30 30 { urn; text; author_name; created_time; num_likes; num_comments } 31 31 32 - let jsont = 33 - Json.Object.map ~kind:"post" 32 + let json = 33 + Json.Codec.Object.map ~kind:"post" 34 34 (fun urn text author_name created_time num_likes num_comments -> 35 35 { 36 36 urn = Option.value ~default:"" urn; ··· 40 40 num_likes = Option.value ~default:0 num_likes; 41 41 num_comments = Option.value ~default:0 num_comments; 42 42 }) 43 - |> Json.Object.opt_mem "urn" Json.string ~enc:(fun p -> Some p.urn) 44 - |> Json.Object.opt_mem "commentary" Json.string ~enc:(fun p -> Some p.text) 45 - |> Json.Object.opt_mem "authorName" Json.string ~enc:(fun p -> 43 + |> Json.Codec.Object.opt_mem "urn" Json.Codec.string ~enc:(fun p -> 44 + Some p.urn) 45 + |> Json.Codec.Object.opt_mem "commentary" Json.Codec.string ~enc:(fun p -> 46 + Some p.text) 47 + |> Json.Codec.Object.opt_mem "authorName" Json.Codec.string ~enc:(fun p -> 46 48 Some p.author_name) 47 - |> Json.Object.opt_mem "createdTime" Json.int ~enc:(fun p -> 49 + |> Json.Codec.Object.opt_mem "createdTime" Json.Codec.int ~enc:(fun p -> 48 50 Some p.created_time) 49 - |> Json.Object.opt_mem "numLikes" Json.int ~enc:(fun p -> Some p.num_likes) 50 - |> Json.Object.opt_mem "numComments" Json.int ~enc:(fun p -> 51 + |> Json.Codec.Object.opt_mem "numLikes" Json.Codec.int ~enc:(fun p -> 52 + Some p.num_likes) 53 + |> Json.Codec.Object.opt_mem "numComments" Json.Codec.int ~enc:(fun p -> 51 54 Some p.num_comments) 52 - |> Json.Object.skip_unknown |> Json.Object.finish 55 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish 53 56 54 - let feed_jsont = 55 - Json.Object.map ~kind:"feed_response" (fun elements -> 57 + let feed_json = 58 + Json.Codec.Object.map ~kind:"feed_response" (fun elements -> 56 59 Option.value ~default:[] elements) 57 - |> Json.Object.opt_mem "elements" (Json.list jsont) ~enc:(fun ps -> Some ps) 58 - |> Json.Object.skip_unknown |> Json.Object.finish 60 + |> Json.Codec.Object.opt_mem "elements" (Json.Codec.list json) ~enc:(fun ps -> 61 + Some ps) 62 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish 59 63 60 64 (** Codec for a single post from a normalized Voyager API response. Extracts the 61 65 first update from the [included] array that has a [commentary] field. *) 62 - let normalized_jsont = 63 - Json.Object.map ~kind:"normalized_post_response" (fun included -> 66 + let normalized_json = 67 + Json.Codec.Object.map ~kind:"normalized_post_response" (fun included -> 64 68 match included with Some (p :: _) -> p | _ -> v ~urn:"" ()) 65 - |> Json.Object.opt_mem "included" (Json.list jsont) ~enc:(fun p -> 69 + |> Json.Codec.Object.opt_mem "included" (Json.Codec.list json) ~enc:(fun p -> 66 70 Some [ p ]) 67 - |> Json.Object.skip_unknown |> Json.Object.finish 71 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish
+6 -6
lib/post.mli
··· 38 38 val equal : t -> t -> bool 39 39 (** [equal a b] is [true] if [a] and [b] are equal. *) 40 40 41 - val jsont : t Json.codec 42 - (** [jsont] is a JSON codec for individual posts. *) 41 + val json : t Json.codec 42 + (** [json] is a JSON codec for individual posts. *) 43 43 44 - val feed_jsont : t list Json.codec 45 - (** [feed_jsont] is a JSON codec for feed responses (extracts [elements]). *) 44 + val feed_json : t list Json.codec 45 + (** [feed_json] is a JSON codec for feed responses (extracts [elements]). *) 46 46 47 - val normalized_jsont : t Json.codec 48 - (** [normalized_jsont] is a JSON codec for a single post from a normalised 47 + val normalized_json : t Json.codec 48 + (** [normalized_json] is a JSON codec for a single post from a normalised 49 49 Voyager API response (extracts from the [included] array). *)
+25 -24
lib/profile.ml
··· 42 42 ?(headline = "") ?(summary = "") ?(location = "") () = 43 43 { public_id; entity_urn; first_name; last_name; headline; summary; location } 44 44 45 - let jsont = 46 - Json.Object.map ~kind:"profile" 45 + let json = 46 + Json.Codec.Object.map ~kind:"profile" 47 47 (fun public_id entity_urn first_name last_name headline summary location -> 48 48 { 49 49 public_id = Option.value ~default:"" public_id; ··· 54 54 summary = Option.value ~default:"" summary; 55 55 location = Option.value ~default:"" location; 56 56 }) 57 - |> Json.Object.opt_mem "miniProfile.publicIdentifier" Json.string 57 + |> Json.Codec.Object.opt_mem "miniProfile.publicIdentifier" Json.Codec.string 58 58 ~enc:(fun p -> Some p.public_id) 59 - |> Json.Object.opt_mem "miniProfile.entityUrn" Json.string ~enc:(fun p -> 60 - Some p.entity_urn) 61 - |> Json.Object.opt_mem "firstName" Json.string ~enc:(fun p -> 59 + |> Json.Codec.Object.opt_mem "miniProfile.entityUrn" Json.Codec.string 60 + ~enc:(fun p -> Some p.entity_urn) 61 + |> Json.Codec.Object.opt_mem "firstName" Json.Codec.string ~enc:(fun p -> 62 62 Some p.first_name) 63 - |> Json.Object.opt_mem "lastName" Json.string ~enc:(fun p -> 63 + |> Json.Codec.Object.opt_mem "lastName" Json.Codec.string ~enc:(fun p -> 64 64 Some p.last_name) 65 - |> Json.Object.opt_mem "headline" Json.string ~enc:(fun p -> 65 + |> Json.Codec.Object.opt_mem "headline" Json.Codec.string ~enc:(fun p -> 66 66 Some p.headline) 67 - |> Json.Object.opt_mem "summary" Json.string ~enc:(fun p -> Some p.summary) 68 - |> Json.Object.opt_mem "locationName" Json.string ~enc:(fun p -> 67 + |> Json.Codec.Object.opt_mem "summary" Json.Codec.string ~enc:(fun p -> 68 + Some p.summary) 69 + |> Json.Codec.Object.opt_mem "locationName" Json.Codec.string ~enc:(fun p -> 69 70 Some p.location) 70 - |> Json.Object.skip_unknown |> Json.Object.finish 71 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish 71 72 72 73 (** Codec for miniProfile objects found in the [included] array of normalized 73 74 LinkedIn Voyager API responses. Maps [occupation] to [headline]. *) 74 - let mini_profile_jsont = 75 - Json.Object.map ~kind:"mini_profile" 75 + let mini_profile_json = 76 + Json.Codec.Object.map ~kind:"mini_profile" 76 77 (fun public_id entity_urn first_name last_name occupation -> 77 78 { 78 79 public_id = Option.value ~default:"" public_id; ··· 83 84 summary = ""; 84 85 location = ""; 85 86 }) 86 - |> Json.Object.opt_mem "publicIdentifier" Json.string ~enc:(fun p -> 87 - Some p.public_id) 88 - |> Json.Object.opt_mem "entityUrn" Json.string ~enc:(fun p -> 87 + |> Json.Codec.Object.opt_mem "publicIdentifier" Json.Codec.string 88 + ~enc:(fun p -> Some p.public_id) 89 + |> Json.Codec.Object.opt_mem "entityUrn" Json.Codec.string ~enc:(fun p -> 89 90 Some p.entity_urn) 90 - |> Json.Object.opt_mem "firstName" Json.string ~enc:(fun p -> 91 + |> Json.Codec.Object.opt_mem "firstName" Json.Codec.string ~enc:(fun p -> 91 92 Some p.first_name) 92 - |> Json.Object.opt_mem "lastName" Json.string ~enc:(fun p -> 93 + |> Json.Codec.Object.opt_mem "lastName" Json.Codec.string ~enc:(fun p -> 93 94 Some p.last_name) 94 - |> Json.Object.opt_mem "occupation" Json.string ~enc:(fun p -> 95 + |> Json.Codec.Object.opt_mem "occupation" Json.Codec.string ~enc:(fun p -> 95 96 Some p.headline) 96 - |> Json.Object.skip_unknown |> Json.Object.finish 97 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish 97 98 98 99 let empty = 99 100 { ··· 108 109 109 110 (** Codec for the normalized [/voyager/api/me] response. Extracts the first 110 111 miniProfile from the [included] array. *) 111 - let me_jsont = 112 - Json.Object.map ~kind:"me_response" (fun included -> 112 + let me_json = 113 + Json.Codec.Object.map ~kind:"me_response" (fun included -> 113 114 match included with Some (p :: _) -> p | _ -> empty) 114 - |> Json.Object.opt_mem "included" (Json.list mini_profile_jsont) 115 + |> Json.Codec.Object.opt_mem "included" (Json.Codec.list mini_profile_json) 115 116 ~enc:(fun p -> Some [ p ]) 116 - |> Json.Object.skip_unknown |> Json.Object.finish 117 + |> Json.Codec.Object.skip_unknown |> Json.Codec.Object.finish
+4 -4
lib/profile.mli
··· 45 45 val equal : t -> t -> bool 46 46 (** [equal a b] is [true] if [a] and [b] are equal. *) 47 47 48 - val jsont : t Json.codec 49 - (** [jsont] is a JSON codec for profiles (from profileView responses). *) 48 + val json : t Json.codec 49 + (** [json] is a JSON codec for profiles (from profileView responses). *) 50 50 51 - val me_jsont : t Json.codec 52 - (** [me_jsont] is a JSON codec for the /me endpoint response. *) 51 + val me_json : t Json.codec 52 + (** [me_json] is a JSON codec for the /me endpoint response. *)
+1 -1
test/dune
··· 1 1 (test 2 2 (name test) 3 - (libraries linkedin alcotest json json.bytesrw fmt)) 3 + (libraries linkedin alcotest json fmt))
+16 -16
test/test_post.ml
··· 1 1 let decode_ok codec s = 2 - match Json_bytesrw.decode_string codec s with 2 + match Json.of_string codec s with 3 3 | Ok v -> v 4 - | Error e -> Alcotest.failf "decode error: %s" e 4 + | Error e -> Alcotest.failf "decode error: %s" (Json.Error.to_string e) 5 5 6 6 let roundtrip codec v = 7 - match Json_bytesrw.encode_string codec v with 8 - | Error e -> Alcotest.failf "encode error: %s" e 9 - | Ok s -> ( 10 - match Json_bytesrw.decode_string codec s with 11 - | Ok v' -> v' 12 - | Error e -> Alcotest.failf "decode error on roundtrip: %s\njson: %s" e s) 7 + let s = Json.to_string codec v in 8 + match Json.of_string codec s with 9 + | Ok v' -> v' 10 + | Error e -> 11 + Alcotest.failf "decode error on roundtrip: %s\njson: %s" 12 + (Json.Error.to_string e) s 13 13 14 14 let test_decode () = 15 15 let json = 16 16 {|{"urn":"urn:li:activity:123","commentary":"Hello world!","authorName":"Alice Smith","createdTime":1700000000,"numLikes":42,"numComments":5}|} 17 17 in 18 - let p = decode_ok Linkedin.Post.jsont json in 18 + let p = decode_ok Linkedin.Post.json json in 19 19 Alcotest.(check string) "urn" "urn:li:activity:123" (Linkedin.Post.urn p); 20 20 Alcotest.(check string) "text" "Hello world!" (Linkedin.Post.text p); 21 21 Alcotest.(check string) ··· 27 27 28 28 let test_minimal () = 29 29 let json = {|{}|} in 30 - let p = decode_ok Linkedin.Post.jsont json in 30 + let p = decode_ok Linkedin.Post.json json in 31 31 Alcotest.(check string) "urn defaults to empty" "" (Linkedin.Post.urn p); 32 32 Alcotest.(check string) "text defaults to empty" "" (Linkedin.Post.text p); 33 33 Alcotest.(check int) "num_likes defaults to 0" 0 (Linkedin.Post.num_likes p) ··· 36 36 let json = 37 37 {|{"urn":"urn:li:activity:456","commentary":"test","extraField":"ignored","nested":{"a":1}}|} 38 38 in 39 - let p = decode_ok Linkedin.Post.jsont json in 39 + let p = decode_ok Linkedin.Post.json json in 40 40 Alcotest.(check string) "urn" "urn:li:activity:456" (Linkedin.Post.urn p); 41 41 Alcotest.(check string) "text" "test" (Linkedin.Post.text p) 42 42 ··· 46 46 ~author_name:"Bob" ~created_time:1700000000 ~num_likes:10 ~num_comments:3 47 47 () 48 48 in 49 - let p' = roundtrip Linkedin.Post.jsont p in 49 + let p' = roundtrip Linkedin.Post.json p in 50 50 Alcotest.(check bool) "roundtrip" true (Linkedin.Post.equal p p') 51 51 52 52 let test_constructor_defaults () = ··· 61 61 let json = 62 62 {|{"elements":[{"urn":"urn:li:activity:1","commentary":"First post","numLikes":5,"numComments":1},{"urn":"urn:li:activity:2","commentary":"Second post","numLikes":10,"numComments":2}]}|} 63 63 in 64 - let posts = decode_ok Linkedin.Post.feed_jsont json in 64 + let posts = decode_ok Linkedin.Post.feed_json json in 65 65 Alcotest.(check int) "post count" 2 (List.length posts); 66 66 let first = List.hd posts in 67 67 Alcotest.(check string) ··· 74 74 75 75 let test_feed_empty () = 76 76 let json = {|{"elements":[]}|} in 77 - let posts = decode_ok Linkedin.Post.feed_jsont json in 77 + let posts = decode_ok Linkedin.Post.feed_json json in 78 78 Alcotest.(check int) "empty feed" 0 (List.length posts) 79 79 80 80 let test_feed_missing_elements () = 81 81 let json = {|{}|} in 82 - let posts = decode_ok Linkedin.Post.feed_jsont json in 82 + let posts = decode_ok Linkedin.Post.feed_json json in 83 83 Alcotest.(check int) 84 84 "missing elements defaults to empty" 0 (List.length posts) 85 85 ··· 87 87 let json = 88 88 {|{"elements":[{"urn":"urn:li:activity:1","commentary":"test"}],"paging":{"count":10,"start":0},"metadata":{}}|} 89 89 in 90 - let posts = decode_ok Linkedin.Post.feed_jsont json in 90 + let posts = decode_ok Linkedin.Post.feed_json json in 91 91 Alcotest.(check int) "post count" 1 (List.length posts) 92 92 93 93 let test_equal () =
+17 -17
test/test_profile.ml
··· 1 1 let decode_ok codec s = 2 - match Json_bytesrw.decode_string codec s with 2 + match Json.of_string codec s with 3 3 | Ok v -> v 4 - | Error e -> Alcotest.failf "decode error: %s" e 4 + | Error e -> Alcotest.failf "decode error: %s" (Json.Error.to_string e) 5 5 6 6 let roundtrip codec v = 7 - match Json_bytesrw.encode_string codec v with 8 - | Error e -> Alcotest.failf "encode error: %s" e 9 - | Ok s -> ( 10 - match Json_bytesrw.decode_string codec s with 11 - | Ok v' -> v' 12 - | Error e -> Alcotest.failf "decode error on roundtrip: %s\njson: %s" e s) 7 + let s = Json.to_string codec v in 8 + match Json.of_string codec s with 9 + | Ok v' -> v' 10 + | Error e -> 11 + Alcotest.failf "decode error on roundtrip: %s\njson: %s" 12 + (Json.Error.to_string e) s 13 13 14 14 let test_decode_me () = 15 15 let json = 16 16 {|{"included":[{"publicIdentifier":"johndoe","entityUrn":"urn:li:fs_miniProfile:abc123","firstName":"John","lastName":"Doe","occupation":"Software Engineer"}]}|} 17 17 in 18 - let p = decode_ok Linkedin.Profile.me_jsont json in 18 + let p = decode_ok Linkedin.Profile.me_json json in 19 19 Alcotest.(check string) "public_id" "johndoe" (Linkedin.Profile.public_id p); 20 20 Alcotest.(check string) 21 21 "entity_urn" "urn:li:fs_miniProfile:abc123" ··· 30 30 let json = 31 31 {|{"miniProfile.publicIdentifier":"janedoe","miniProfile.entityUrn":"urn:li:fs_miniProfile:xyz789","firstName":"Jane","lastName":"Doe","headline":"Product Manager","summary":"Building great products.","locationName":"New York"}|} 32 32 in 33 - let p = decode_ok Linkedin.Profile.jsont json in 33 + let p = decode_ok Linkedin.Profile.json json in 34 34 Alcotest.(check string) "public_id" "janedoe" (Linkedin.Profile.public_id p); 35 35 Alcotest.(check string) "first_name" "Jane" (Linkedin.Profile.first_name p); 36 36 Alcotest.(check string) ··· 39 39 40 40 let test_minimal () = 41 41 let json = {|{}|} in 42 - let p = decode_ok Linkedin.Profile.me_jsont json in 42 + let p = decode_ok Linkedin.Profile.me_json json in 43 43 Alcotest.(check string) 44 44 "public_id defaults to empty" "" 45 45 (Linkedin.Profile.public_id p); ··· 49 49 50 50 let test_empty_included () = 51 51 let json = {|{"included":[]}|} in 52 - let p = decode_ok Linkedin.Profile.me_jsont json in 52 + let p = decode_ok Linkedin.Profile.me_json json in 53 53 Alcotest.(check string) 54 54 "public_id defaults to empty" "" 55 55 (Linkedin.Profile.public_id p) ··· 58 58 let json = 59 59 {|{"included":[{"publicIdentifier":"test","firstName":"Test","unknownField":"ignored","anotherUnknown":42}]}|} 60 60 in 61 - let p = decode_ok Linkedin.Profile.me_jsont json in 61 + let p = decode_ok Linkedin.Profile.me_json json in 62 62 Alcotest.(check string) "public_id" "test" (Linkedin.Profile.public_id p); 63 63 Alcotest.(check string) "first_name" "Test" (Linkedin.Profile.first_name p) 64 64 65 65 let test_roundtrip_me () = 66 - (* me_jsont only roundtrips fields in mini_profile_jsont: public_id, 66 + (* me_json only roundtrips fields in mini_profile_json: public_id, 67 67 entity_urn, first_name, last_name, headline (as occupation). 68 68 summary and location are not part of the /me response. *) 69 69 let p = 70 70 Linkedin.Profile.v ~public_id:"alice" ~entity_urn:"urn:li:abc" 71 71 ~first_name:"Alice" ~last_name:"Smith" ~headline:"Engineer" () 72 72 in 73 - let p' = roundtrip Linkedin.Profile.me_jsont p in 73 + let p' = roundtrip Linkedin.Profile.me_json p in 74 74 Alcotest.(check string) "public_id" "alice" (Linkedin.Profile.public_id p'); 75 75 Alcotest.(check string) 76 76 "entity_urn" "urn:li:abc" ··· 85 85 ~first_name:"Bob" ~last_name:"Jones" ~headline:"Designer" 86 86 ~summary:"Creative" ~location:"Berlin" () 87 87 in 88 - let p' = roundtrip Linkedin.Profile.jsont p in 88 + let p' = roundtrip Linkedin.Profile.json p in 89 89 Alcotest.(check bool) "roundtrip" true (Linkedin.Profile.equal p p') 90 90 91 91 let test_constructor_defaults () = ··· 189 189 ] 190 190 }|} 191 191 in 192 - let p = decode_ok Linkedin.Profile.me_jsont json in 192 + let p = decode_ok Linkedin.Profile.me_json json in 193 193 Alcotest.(check string) "public_id" "janedoe" (Linkedin.Profile.public_id p); 194 194 Alcotest.(check string) 195 195 "entity_urn"