stof_github/
lib.rs

1//
2// Copyright 2024 Formata, Inc. All rights reserved.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//    http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17use std::{collections::HashMap, sync::Arc, time::Duration};
18use anyhow::Result;
19use stof::{lang::SError, Format, Library, SDoc, SVal};
20use ureq::{Agent, AgentBuilder};
21
22
23/// Stof GitHub Library.
24#[derive(Default)]
25pub struct GitHubLibrary;
26impl Library for GitHubLibrary {
27    fn scope(&self) -> String {
28        "GitHub".to_string()
29    }
30
31    fn call(&self, pid: &str, doc: &mut SDoc, name: &str, parameters: &mut Vec<SVal>) -> Result<SVal, SError> {
32        match name {
33            // Allows users to add GitHub repositories as formats at runtime
34            // Recommended to use this in an #[init] function
35            // Will add the format as available in every Stof scope
36            "addFormat" => {
37                // GitHub.addFormat(owner: str, repo: str, repo_id: str, headers: vec)
38                // Parameters:
39                // - owner (REQUIRED)
40                // - repo (REQUIRED)
41                // - repo_id (OPTIONAL) default is to use 'repo' for the format repository ID (see format implementation below)
42                // - headers (OPTIONAL) additional headers to add to this format (see format implementation below)
43                if parameters.len() >= 2 {
44                    let owner = parameters[0].to_string();
45                    let repo = parameters[1].to_string();
46                    let mut repo_id = repo.clone();
47                    let mut headers: Vec<(String, String)> = Vec::new();
48
49                    if parameters.len() > 2 {
50                        match &parameters[2] {
51                            SVal::Array(vals) => {
52                                for val in vals {
53                                    match val {
54                                        SVal::Tuple(tup) => {
55                                            if tup.len() == 2 {
56                                                headers.push((tup[0].to_string(), tup[1].to_string()));
57                                            }
58                                        },
59                                        _ => {}
60                                    }
61                                }
62                            },
63                            SVal::String(id) => {
64                                repo_id = id.to_owned();
65                            },
66                            _ => {}
67                        }
68                    }
69                    if parameters.len() > 3 {
70                        match &parameters[3] {
71                            SVal::Array(vals) => {
72                                for val in vals {
73                                    match val {
74                                        SVal::Tuple(tup) => {
75                                            if tup.len() == 2 {
76                                                headers.push((tup[0].to_string(), tup[1].to_string()));
77                                            }
78                                        },
79                                        _ => {}
80                                    }
81                                }
82                            },
83                            SVal::String(id) => {
84                                repo_id = id.to_owned();
85                            },
86                            _ => {}
87                        }
88                    }
89
90                    let mut format = GitHubFormat::new(&repo, &owner);
91                    format.repo_id = repo_id;
92                    for (key, value) in headers {
93                        format.headers.insert(key, value);
94                    }
95                    doc.load_format(Arc::new(format));
96                    return Ok(SVal::Void);
97                }
98                return Err(SError::custom(pid, &doc, "GitHubLibError", "GitHub.addFormat requires at least 2 parameters: GitHub.addFormat(owner: str, repo: str, repo_id?: str, headers?: vec)"));
99            },
100            _ => {}
101        }
102        Err(SError::custom(pid, &doc, "GitHubLibError", &format!("'{}' is not a function in the GitHub library", name)))
103    }
104}
105
106
107/// Stof GitHub Format.
108pub struct GitHubFormat {
109    /// Format Repo ID.
110    /// Ex. "formata" means format is "github:formata".
111    pub repo_id: String,
112
113    /// Repository owner.
114    pub owner: String,
115
116    /// Repository name.
117    pub repo: String,
118
119    /// Headers.
120    pub headers: HashMap<String, String>,
121
122    /// Agent.
123    pub agent: Agent,
124}
125impl GitHubFormat {
126    /// Create a new GitHub Format.
127    pub fn new(repo: &str, owner: &str) -> Self {
128        let mut headers = HashMap::new();
129        headers.insert("Accept".to_string(), "application/vnd.github.raw+json".to_string());
130        headers.insert("X-GitHub-Api-Version".to_string(), "2022-11-28".to_string());
131        Self {
132            repo_id: repo.to_owned(),
133            owner: owner.to_owned(),
134            repo: repo.to_owned(),
135            headers,
136            agent: AgentBuilder::new()
137                .timeout_read(Duration::from_secs(3))
138                .timeout_write(Duration::from_secs(3))
139                .build(),
140        }
141    }
142
143    /// The URL for a request into this GitHub repository.
144    pub fn url(&self, path: &str) -> String {
145        format!("https://api.github.com/repos/{}/{}/contents/{}", self.owner, self.repo, path)
146    }
147
148    /// Get the string contents for a file path into this GitHub repository.
149    pub fn get(&self, file_path: &str) -> Result<String> {
150        let url = self.url(file_path);
151        let mut request = self.agent.get(&url);
152        for (key, value) in &self.headers {
153            request = request.set(key, value);
154        }
155        let response = request.call()?;
156        Ok(response.into_string()?)
157    }
158}
159impl Format for GitHubFormat {
160    /// How this format will be accessed in Stof.
161    /// For example, if repo_id is "formata", using this library would be the format identifier "github:formata".
162    ///
163    /// `import github:formata "myfile.stof" as Import;`
164    fn format(&self) -> String {
165        format!("github:{}", self.repo_id)
166    }
167
168    /// The GitHub format only allows a file import.
169    /// Gets the contents of the file at a path in this GitHub repo, then imports it as a string using the file extension.
170    /// Will error if a Format with the requested file extension is not available in the doc.
171    fn file_import(&self, pid: &str, doc: &mut SDoc, format: &str, full_path: &str, extension: &str, as_name: &str) -> Result<(), SError> {
172        let res = self.get(full_path);
173        match res {
174            Ok(contents) => {
175                doc.string_import(pid, extension, &contents, as_name)
176            },
177            Err(error) => {
178                Err(SError::fmt(pid, &doc, format, &format!("error getting file contents from GitHub: {}", error.to_string())))
179            }
180        }
181    }
182}
183
184
185#[cfg(test)]
186mod tests {
187    use std::sync::Arc;
188    use stof::SDoc;
189    use crate::GitHubLibrary;
190
191    #[test]
192    fn test() {
193        let mut doc = SDoc::default();
194        doc.load_lib(Arc::new(GitHubLibrary::default()));
195        //doc.load_format(Arc::new(GitHubFormat::new("stof", "dev-formata-io"))); // github:stof
196
197        doc.string_import("main", "stof", r#"
198
199            init_stof_github: {
200                // This is a block expression that gets executed while parsing this value - not an object!
201                // Will add the 'github:stof' format for usage in our import statement, because parsing happens top down
202                GitHub.addFormat('dev-formata-io', 'stof');
203                return true;
204            }
205
206            import github:stof "web/deno.json"; // Will import deno.json using the "json" format into "root"
207
208            #[test('@formata/stof')]
209            fn name(): str {
210                return self.name;
211            }
212
213            #[test('Apache-2.0')]
214            fn license(): str {
215                return self.license;
216            }
217
218            #[test]
219            fn init() {
220                assert(self.init_stof_github);
221            }
222
223        "#, "").unwrap();
224
225        let res = doc.run_tests(true, None);
226        match res {
227            Ok(message) => {
228                println!("{message}");
229            },
230            Err(error) => {
231                panic!("{error}");
232            }
233        }
234    }
235}