Current section

Files

Jump to
midas_sdk src midas sdk linkedin.gleam
Raw

src/midas/sdk/linkedin.gleam

import gleam/bit_array
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/http/response.{Response}
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result.{try}
import gleam/string
import gleam/uri.{Uri}
import midas/task as t
import snag
// https://www.linkedin.com/developers/apps/
pub type App {
App(client_id: String, client_secret: String, redirect_uri: String)
}
const auth_host = "www.linkedin.com"
const auth_path = "/oauth/v2/authorization"
// https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?tabs=HTTPS1
pub fn authenticate(app, scopes) {
let App(client_id, client_secret, redirect_uri) = app
let state = int.to_string(int.random(1_000_000_000))
let url = auth_url(client_id, redirect_uri, scopes, state)
use redirect <- t.do(t.follow(url))
use #(code, returned_state) <- t.try(
auth_redirect(redirect) |> result.map_error(snag.new),
)
use Nil <- t.try(case returned_state == state {
True -> Ok(Nil)
False -> Error(snag.new("returned state was not equal to sent state"))
})
let request = token_request(client_id, redirect_uri, client_secret, code)
use response <- t.do(t.fetch(request))
use token <- t.try(token_response(response))
t.Done(token)
}
fn auth_url(client_id, redirect_uri, scopes, state) {
let query = [
#("client_id", client_id),
#("response_type", "code"),
#("redirect_uri", redirect_uri),
#("scope", string.join(scopes, " ")),
#("state", state),
]
let query = Some(uri.query_to_string(query))
Uri(Some("https"), None, Some(auth_host), None, auth_path, query, None)
|> uri.to_string
}
pub fn auth_redirect(redirect) {
let Uri(query: query, ..) = redirect
use query <- try(case query {
Some(query) -> Ok(query)
None -> Error("uri did not have a query: " <> string.inspect(redirect))
})
use parts <- try(
uri.parse_query(query)
|> result.replace_error("Failed to parse query: " <> query),
)
use code <- try(key_find(parts, "code"))
use returned_state <- try(key_find(parts, "state"))
Ok(#(code, returned_state))
}
fn key_find(items, key) {
list.key_find(items, key)
|> result.replace_error("Did not find key: " <> key)
}
const token_path = "/oauth/v2/accessToken"
pub fn token_request(client_id, redirect_uri, client_secret, code) {
let query = [
#("grant_type", "authorization_code"),
#("client_id", client_id),
#("redirect_uri", redirect_uri),
#("code", code),
#("client_secret", client_secret),
]
request.new()
|> request.set_host(auth_host)
|> request.set_method(http.Post)
|> request.set_path(token_path)
|> request.prepend_header("content-type", "application/x-www-form-urlencoded")
|> request.set_body(bit_array.from_string(uri.query_to_string(query)))
}
pub fn token_response(response) {
let Response(body: body, ..) = response
let assert Ok(body) = bit_array.to_string(body)
let decoder = {
use token <- decode.field("access_token", decode.string)
decode.success(token)
}
let assert Ok(token) = json.parse(body, decoder)
Ok(token)
}
pub const api_host = "api.linkedin.com"
fn base_request(token) {
request.new()
|> request.set_host(api_host)
|> request.prepend_header("Authorization", string.append("Bearer ", token))
|> request.set_header("X-Restli-Protocol-Version", "2.0.0")
|> request.set_header("LinkedIn-Version", "202408")
}
fn get(token, path) {
base_request(token)
|> request.set_path(path)
|> request.set_body(<<>>)
}
fn post(token, path, mime, content) {
base_request(token)
|> request.set_method(http.Post)
|> request.set_path(path)
|> request.prepend_header("content-type", mime)
|> request.set_body(content)
}
fn post_json(token, path, data) {
let body = json.to_string(data) |> bit_array.from_string
post(token, path, "application/json", body)
}
pub fn userinfo(token) {
let request = userinfo_request(token)
use response <- t.do(t.fetch(request))
use response <- t.try(userinfo_response(response))
t.Done(response)
}
pub fn userinfo_request(token) {
let path = "/v2/userinfo"
get(token, path)
}
pub type Userinfo {
Userinfo(sub: String)
}
fn userinfo_decoder() {
use sub <- decode.field("sub", decode.string)
decode.success(Userinfo(sub))
}
pub fn userinfo_response(response: response.Response(BitArray)) {
let response.Response(body:, ..) = response
json.parse_bits(body, userinfo_decoder())
|> result.map_error(fn(reason) {
snag.new(string.inspect(reason))
|> snag.layer("failed to decode sites")
})
}
fn error_decoder() {
use message <- decode.field("message", decode.string)
decode.success(message)
}
pub type Author {
Person(id: String)
Organization(id: String)
}
pub fn author_to_urn(author) {
let type_ = case author {
Person(_) -> "person"
Organization(_) -> "organization"
}
"urn:li:" <> type_ <> ":" <> author.id
}
pub type Image {
Image(id: String)
}
pub fn image_to_urn(image) {
let Image(id) = image
"urn:li:image:" <> id
}
pub type Content {
// https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/version/article-ads-integrations?view=li-lms-2024-08&tabs=http#workflow
Article(
description: Option(String),
source: String,
thumbnail: Option(Image),
thumbnail_alt_text: Option(String),
title: String,
)
}
pub fn content_to_json(content) {
case content {
Article(d, s, t, ta, title) ->
json.object([
#(
"article",
json.object([
#("description", json.nullable(d, json.string)),
#("source", json.string(s)),
#(
"thumbnail",
json.nullable(t, fn(t) { json.string(image_to_urn(t)) }),
),
#("thumbnailAltText", json.nullable(ta, json.string)),
#("title", json.string(title)),
]),
),
])
}
}
pub type Visability {
Connections
Public
LoggedIn
Container
}
pub fn visability_to_string(visability) {
case visability {
Connections -> "CONNECTIONS"
Public -> "PUBLIC"
LoggedIn -> "LOGGEDIN"
Container -> "CONTAINER"
}
}
pub fn create_post(token, user_id, content, commentary) {
let request = create_post_request(token, user_id, content, commentary)
use response <- t.do(t.fetch(request))
use response <- t.try(create_post_response(response))
t.Done(response)
}
// how to set articles
// https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/version/article-ads-integrations?view=li-lms-2024-08&tabs=http#workflow
pub fn create_post_request(token, author, content, commentary) {
let path = "/rest/posts"
let data =
json.object({
let always = [
#("author", json.string(author_to_urn(author))),
#("commentary", json.string(commentary)),
#(
"distribution",
json.object([
#("feedDistribution", json.string("MAIN_FEED")),
#("targetEntities", json.array([], json.string)),
#("thirdPartyDistributionChannels", json.array([], json.string)),
]),
),
#("isReshareDisabledByAuthor", json.bool(False)),
#("lifecycleState", json.string("PUBLISHED")),
#("visibility", json.string(visability_to_string(Public))),
]
case content {
Some(content) -> [#("content", content_to_json(content)), ..always]
None -> always
}
})
post(
token,
path,
"application/json",
json.to_string(data)
|> bit_array.from_string,
)
}
pub fn create_post_response(response: response.Response(BitArray)) {
use id <- try(case response.status {
201 ->
case response.get_header(response, "x-restli-id") {
Ok(id) -> Ok(id)
_ -> Error(snag.new("no post id"))
}
status -> {
case json.parse_bits(response.body, error_decoder()) {
Ok(message) -> snag.new(message)
Error(reason) -> snag.new(string.inspect(reason))
}
|> snag.layer("post not created status: " <> int.to_string(status))
|> Error
}
})
Ok(id)
}
pub fn get_posts(token, author) {
let request = get_posts_request(token, author)
use response <- t.do(t.fetch(request))
// use response <- t.try(get_posts_response(response))
t.Done(response)
}
pub fn get_posts_request(token, author) {
let path = "/rest/posts"
get(token, path)
|> request.set_header("X-RestLi-Method", "FINDER")
|> request.set_query([
#("author", author_to_urn(author)),
#("q", "author"),
#("count", "10"),
#("sortBy", "LAST_MODIFIED"),
])
}
pub type Share {
Share(id: String)
}
fn share_to_urn(share) {
let Share(id) = share
"urn:li:share:" <> id
}
pub fn get_post(token, share) {
let request = get_post_request(token, share)
use response <- t.do(t.fetch(request))
// use response <- t.try(get_post_response(response))
t.Done(response)
}
pub fn get_post_request(token, share) {
let path = "/rest/posts/" <> uri.percent_encode(share_to_urn(share))
get(token, path)
}
// images
pub fn upload_image(token, owner, bits) {
use InitializeImageUpload(_exp, url, image) <- t.do(initialize_image_upload(
token,
owner,
))
let assert Ok(uri) = uri.parse(url)
let assert Ok(r) = request.from_uri(uri)
let upload =
r
|> request.set_method(http.Post)
|> request.prepend_header("Authorization", string.append("Bearer ", token))
|> request.set_body(bits)
use response <- t.do(t.fetch(upload))
case response.status {
201 -> {
let assert "urn:li:image:" <> id = image
t.done(Image(id))
}
_ ->
t.abort(snag.new("failed to upload image")// bit_array.to_string(response.body)
)
}
}
pub fn initialize_image_upload(token, owner) {
let request = initialize_image_upload_request(token, owner)
use response <- t.do(t.fetch(request))
use response <- t.try(initialize_image_upload_response(response))
t.Done(response)
}
pub fn initialize_image_upload_request(token, owner) {
let path = "/rest/images"
let data =
json.object([
#(
"initializeUploadRequest",
json.object([#("owner", json.string(author_to_urn(owner)))]),
),
])
post_json(token, path, data)
|> request.set_query([#("action", "initializeUpload")])
}
pub type InitializeImageUpload {
InitializeImageUpload(
upload_url_expires_at: Int,
upload_url: String,
image: String,
)
}
pub fn initialize_image_upload_decoder() {
use upload <- decode.field("value", {
use upload_url_expires_at <- decode.field("uploadUrlExpiresAt", decode.int)
use upload_url <- decode.field("uploadUrl", decode.string)
use image <- decode.field("image", decode.string)
decode.success(InitializeImageUpload(
upload_url_expires_at,
upload_url,
image,
))
})
decode.success(upload)
}
pub fn initialize_image_upload_response(response: response.Response(BitArray)) {
use id <- try(case response.status {
200 ->
json.parse_bits(response.body, initialize_image_upload_decoder())
|> result.map_error(fn(reason) {
snag.new(string.inspect(reason))
|> snag.layer("failed to decode initialize image upload ")
})
status -> {
case json.parse_bits(response.body, error_decoder()) {
Ok(message) -> snag.new(message)
Error(reason) -> snag.new(string.inspect(reason))
}
|> snag.layer("post not created status: " <> int.to_string(status))
|> Error
}
})
Ok(id)
}