Skip to main content

gen_linkedin/
posts.rs

1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use url::Url;
4
5use crate::{auth::TokenProvider, client::Client, Error};
6
7/// Response returned when a post is created.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PostResponse {
10    /// The LinkedIn-generated ID/URN of the new post (from `x-restli-id` or `location`).
11    pub id: String,
12}
13
14/// Parameters for creating a simple text post.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TextPost {
17    /// The actor URN (e.g., `urn:li:person:...` or `urn:li:organization:...`).
18    pub author_urn: String,
19    /// The post text content.
20    pub text: String,
21    /// Optional link to include with the post (will be appended to text for now).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub link: Option<url::Url>,
24    /// Visibility setting, default PUBLIC.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub visibility: Option<String>,
27}
28
29impl TextPost {
30    /// Construct a new text post.
31    pub fn new(author_urn: impl Into<String>, text: impl Into<String>) -> Self {
32        Self {
33            author_urn: author_urn.into(),
34            text: text.into(),
35            link: None,
36            visibility: None,
37        }
38    }
39    /// Attach a link to the post.
40    pub fn with_link(mut self, link: Url) -> Self {
41        self.link = Some(link);
42        self
43    }
44    /// Set a visibility value such as "PUBLIC" or "CONNECTIONS".
45    pub fn with_visibility(mut self, vis: impl Into<String>) -> Self {
46        self.visibility = Some(vis.into());
47        self
48    }
49}
50
51/// Client for interacting with LinkedIn's Posts API.
52pub struct PostsClient<TP: TokenProvider> {
53    inner: Client<TP>,
54}
55
56impl<TP: TokenProvider> PostsClient<TP> {
57    /// Construct a new Posts client from the base client.
58    #[must_use]
59    pub fn new(inner: Client<TP>) -> Self {
60        Self { inner }
61    }
62
63    /// Create a text post using the LinkedIn Posts REST API.
64    ///
65    /// Notes
66    /// - Requires the header `X-Restli-Protocol-Version: 2.0.0`.
67    /// - On success, LinkedIn typically returns 201 with `x-restli-id` or `location` headers.
68    pub async fn create_text_post(&self, post: &TextPost) -> Result<PostResponse, Error> {
69        // Build endpoint
70        let url = self
71            .inner
72            .base()
73            .join("rest/posts")
74            .map_err(|e| Error::Config(format!("invalid base url: {e}")))?;
75
76        // Compose commentary, appending link if provided.
77        let mut commentary = post.text.trim().to_string();
78        if let Some(link) = &post.link {
79            if !commentary.is_empty() {
80                commentary.push_str("\n\n");
81            }
82            commentary.push_str(link.as_str());
83        }
84
85        // Minimal payload per Posts API examples (PUBLIC visibility, main feed)
86        let body = json!({
87            "author": post.author_urn,
88            "commentary": commentary,
89            "visibility": post.visibility.clone().unwrap_or_else(|| "PUBLIC".to_string()),
90            "distribution": { "feedDistribution": "MAIN_FEED" },
91            "lifecycleState": "PUBLISHED",
92            "isReshareDisabledByAuthor": false
93        });
94
95        // Headers
96        let mut headers = self.inner.auth_headers()?;
97        headers.insert("X-Restli-Protocol-Version", "2.0.0".parse().unwrap());
98        headers.insert("Content-Type", "application/json".parse().unwrap());
99
100        // Execute
101        let resp = self
102            .inner
103            .http()
104            .post(url)
105            .headers(headers)
106            .json(&body)
107            .send()
108            .await?;
109
110        if resp.status().is_success() || resp.status().as_u16() == 201 {
111            // Prefer x-restli-id, else location
112            let id = resp
113                .headers()
114                .get("x-restli-id")
115                .and_then(|v| v.to_str().ok())
116                .map(|s| s.to_string())
117                .or_else(|| {
118                    resp.headers()
119                        .get("location")
120                        .and_then(|v| v.to_str().ok())
121                        .map(|s| s.to_string())
122                })
123                .unwrap_or_else(|| "created".to_string());
124            return Ok(PostResponse { id });
125        }
126
127        if resp.status().as_u16() == 429 {
128            let retry_after = resp
129                .headers()
130                .get("retry-after")
131                .and_then(|v| v.to_str().ok())
132                .and_then(|s| s.parse::<u64>().ok())
133                .unwrap_or(0);
134            return Err(Error::RateLimited { retry_after });
135        }
136
137        let status = resp.status().as_u16();
138        let message = resp.text().await.unwrap_or_default();
139        Err(Error::Api { status, message })
140    }
141}