the_way/
gist.rs

1//! Simple Gist API wrapper
2use std::collections::HashMap;
3
4use chrono::{DateTime, Utc};
5use color_eyre::Help;
6use regex::Regex;
7
8use crate::errors::LostTheWay;
9
10const GITHUB_API_URL: &str = "https://api.github.com";
11const GITHUB_BASE_PATH: &str = "";
12const ACCEPT: &str = "application/vnd.github.v3+json";
13const USER_AGENT: &str = "the-way";
14
15/// Expects URL like `https://gist.github.com/user/<gist_id>`
16/// or `https://gist.github.com/<gist_id>`
17fn gist_id_from_url(gist_url: &str) -> color_eyre::Result<Option<&str>> {
18    let re = Regex::new(r"https://gist\.github\.com/(.+/)?(?P<gist_id>[0-9a-f]+)$")?;
19    Ok(re
20        .captures(gist_url)
21        .and_then(|cap| cap.name("gist_id").map(|gist_id| gist_id.as_str())))
22}
23
24/// Gist code content
25#[derive(Serialize, Debug)]
26pub struct GistContent<'a> {
27    pub content: &'a str,
28}
29
30#[derive(Serialize, Debug)]
31pub struct CreateGistPayload<'a> {
32    pub description: &'a str,
33    pub public: bool,
34    pub files: HashMap<String, GistContent<'a>>,
35}
36
37#[derive(Serialize, Debug)]
38pub struct UpdateGistPayload<'a> {
39    pub description: &'a str,
40    pub files: HashMap<String, Option<GistContent<'a>>>,
41}
42
43#[derive(Deserialize, Debug)]
44pub struct Gist {
45    pub html_url: String,
46    pub id: String,
47    pub updated_at: DateTime<Utc>,
48    pub description: String,
49    pub files: HashMap<String, GistFile>,
50}
51
52#[derive(Deserialize, Debug)]
53pub struct GistFile {
54    pub content: String,
55    pub language: String,
56}
57
58pub struct GistClient<'a> {
59    client: ureq::Agent,
60    access_token: Option<&'a str>,
61}
62
63impl<'a> GistClient<'a> {
64    /// Create a new Gist client
65    pub fn new(access_token: Option<&'a str>) -> color_eyre::Result<Self> {
66        Ok(Self {
67            client: ureq::agent(),
68            access_token,
69        })
70    }
71
72    fn add_headers(&self, request: ureq::Request) -> ureq::Request {
73        let mut request = request
74            .set("user-agent", USER_AGENT)
75            .set("content-type", ACCEPT);
76        if let Some(access_token) = &self.access_token {
77            request = request.set("Authorization", &format!("token {access_token}"));
78        }
79        request
80    }
81
82    fn get_response(response: Result<ureq::Response, ureq::Error>) -> color_eyre::Result<Gist> {
83        match response {
84            Ok(response) => {
85                Ok(response
86                    .into_json::<Gist>()
87                    .map_err(|e| LostTheWay::SyncError {
88                        message: format!("{e}"),
89                    })?)
90            }
91            Err(ureq::Error::Status(code, response)) => Err(LostTheWay::SyncError {
92                message: format!("{code} {}", response.into_string()?),
93            })
94            .suggestion(
95                "Make sure your GitHub access token is valid.\n\
96        Get one from https://github.com/settings/tokens/new (add the \"gist\" scope).\n\
97        Set it to the environment variable $THE_WAY_GITHUB_TOKEN",
98            ),
99            Err(_) => Err(LostTheWay::SyncError {
100                message: "io/transport error".into(),
101            })
102            .suggestion(
103                "Make sure your GitHub access token is valid.\n\
104        Get one from https://github.com/settings/tokens/new (add the \"gist\" scope).\n\
105        Set it to the environment variable $THE_WAY_GITHUB_TOKEN",
106            ),
107        }
108    }
109
110    /// Create a new Gist with the given payload
111    pub fn create_gist(&self, payload: &CreateGistPayload<'_>) -> color_eyre::Result<Gist> {
112        let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
113        let response = self
114            .add_headers(self.client.post(&url))
115            .send_json(serde_json::to_value(payload)?);
116        Self::get_response(response)
117    }
118
119    /// Update an existing Gist
120    pub fn update_gist(
121        &self,
122        gist_id: &str,
123        payload: &UpdateGistPayload<'_>,
124    ) -> color_eyre::Result<Gist> {
125        let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
126        let response = self
127            .add_headers(self.client.request("PATCH", &format!("{url}/{gist_id}")))
128            .send_json(serde_json::to_value(payload)?);
129        Self::get_response(response)
130    }
131
132    /// Retrieve a Gist by ID
133    pub fn get_gist(&self, gist_id: &str) -> color_eyre::Result<Gist> {
134        let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
135        let response = self.add_headers(self.client.get(&format!("{url}/{gist_id}")));
136        Self::get_response(response.call())
137    }
138
139    /// Retrieve a Gist by URL
140    pub fn get_gist_by_url(&self, gist_url: &str) -> color_eyre::Result<Gist> {
141        let gist_id = gist_id_from_url(gist_url)?;
142        match gist_id {
143            Some(gist_id) => self.get_gist(gist_id),
144            None => Err(LostTheWay::GistUrlError {
145                message: format!("Problem extracting gist ID from {gist_url}"),
146            })
147            .suggestion("The URL should look like https://gist.github.com/<user>/<gist_id>."),
148        }
149    }
150
151    /// Delete Gist by ID
152    pub fn delete_gist(&self, gist_id: &str) -> color_eyre::Result<()> {
153        let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
154        let status = self.add_headers(self.client.delete(&format!("{url}/{gist_id}")));
155        if status.call().is_err() {
156            Err(LostTheWay::GistUrlError {
157                message: format!("Couldn't delete gist with ID {gist_id}"),
158            }
159            .into())
160        } else {
161            Ok(())
162        }
163    }
164}