Commit fbb58a34 authored by Wohlgemuth, Jason's avatar Wohlgemuth, Jason
Browse files

feat: Parameterize API endpoint code for use with custom domains

parent 52a5402d
Loading
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -31,7 +31,7 @@ pub async fn run(
    // let langs = gitlab::languages().await?;
    // dbg!(langs);
    // gitlab_merge_request_note_example().await;
    // gitlab_example().await;
    gitlab_example().await;
    // orcid_example().await;
    // ror_example().await;
    Ok(())
+1 −1
Original line number Diff line number Diff line
@@ -34,7 +34,7 @@
                {
                    "name": "tree",
                    "method": "get",
                    "template": "{{ base }}/{{ path }}/git/trees/{{ branch }}?recursive=1"
                    "template": "{{ base }}/repos/{{ path }}/git/trees/{{ branch }}{{ query }}"
                }
            ]
        },
+18 −24
Original line number Diff line number Diff line
//! Module for interacting with GitHub API
//!
use crate::io::api::{Endpoint, RemoteResource, Resource, TreeEntryType};
use crate::io::api::{Endpoint, RemoteResource, TreeEntryType};
use crate::io::ApiResult;
use crate::param;
use color_eyre::eyre::eyre;
@@ -75,20 +75,15 @@ impl TreeEntry {
        self.entry_type.eq(&TreeEntryType::Blob)
    }
}
/// Construct an [`Endpoint`] for any GitHub API host with the repository tree resource registered
pub(crate) fn endpoint_for(host: impl Into<String>) -> Endpoint {
    let tree = Resource::init()
        .name("tree")
        .method("get")
        .template("{{ base }}/repos{{ path }}/git/trees/{{ branch }}{{ query }}")
        .build();
    Endpoint::at(host).resources(vec![tree]).build()
}
/// Fetch repository tree blob paths for a GitHub repository and branch
pub(crate) async fn tree_paths(endpoint: &Endpoint, path: impl Into<String>, branch: impl Into<String>) -> ApiResult<Vec<String>> {
pub(crate) async fn tree_paths(host: impl Into<String>, path: impl Into<String>, branch: impl Into<String>) -> ApiResult<Vec<String>> {
    let endpoint = match Endpoint::from_template("github").map(|e| e.with_domain(host)) {
        | Ok(endpoint) => endpoint,
        | Err(why) => return Err(why),
    };
    let path = path.into();
    let branch = branch.into();
    let recursive_response = match fetch_tree(endpoint, path.as_str(), branch.as_str(), true).await {
    let recursive_response = match fetch_tree(&endpoint, path.as_str(), branch.as_str(), true).await {
        | Ok(data) => data,
        | Err(why) => return Err(why),
    };
@@ -96,14 +91,14 @@ pub(crate) async fn tree_paths(endpoint: &Endpoint, path: impl Into<String>, bra
        let (paths, _) = collect_tree(recursive_response.tree, "");
        return Ok(paths);
    }
    let root_response = match fetch_tree(endpoint, path.as_str(), branch.as_str(), false).await {
    let root_response = match fetch_tree(&endpoint, path.as_str(), branch.as_str(), false).await {
        | Ok(data) if !data.truncated => data,
        | Ok(_) => return Err(eyre!("Failed to fetch complete GitHub tree for {path}")),
        | Err(why) => return Err(why),
    };
    let (mut all_paths, mut pending) = collect_tree(root_response.tree, "");
    while let Some((sha, prefix)) = pending.pop() {
        let subtree_response = match fetch_tree(endpoint, path.as_str(), sha.as_str(), false).await {
        let subtree_response = match fetch_tree(&endpoint, path.as_str(), sha.as_str(), false).await {
            | Ok(data) if !data.truncated => data,
            | Ok(_) => return Err(eyre!("Failed to fetch complete GitHub tree for {path} at subtree {prefix}")),
            | Err(why) => return Err(why),
@@ -114,13 +109,6 @@ pub(crate) async fn tree_paths(endpoint: &Endpoint, path: impl Into<String>, bra
    }
    Ok(all_paths)
}
fn path_for(prefix: &str, path: &str) -> String {
    if prefix.is_empty() {
        path.to_string()
    } else {
        format!("{prefix}/{path}")
    }
}
fn collect_tree(entries: Vec<TreeEntry>, prefix: &str) -> (Vec<String>, Vec<(String, String)>) {
    entries.into_iter().fold((vec![], vec![]), |(mut paths, mut pending), entry| {
        let is_blob = entry.is_blob();
@@ -146,16 +134,22 @@ async fn fetch_tree(endpoint: &Endpoint, path: &str, branch: &str, recursive: bo
    let response = endpoint.invoke("tree", Some(params)).await;
    endpoint.handle::<TreeResponse>(response)
}
fn path_for(prefix: &str, path: &str) -> String {
    if prefix.is_empty() {
        path.to_string()
    } else {
        format!("{prefix}/{path}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_endpoint_for() {
        let endpoint = endpoint_for("api.github.com");
    fn test_template_endpoint_for() {
        let endpoint = Endpoint::from_template("github").map(|e| e.with_domain("api.github.com")).unwrap();
        assert_eq!(endpoint.base(), "https://api.github.com");
        let endpoint = endpoint_for("api.github.com");
        assert!(endpoint.resources.iter().any(|r| r.name == "tree"));
    }
    #[test]
+58 −59
Original line number Diff line number Diff line
@@ -2,9 +2,7 @@
//!
// TODO: Add custom field list and query pair type
// TODO: Finish modeling necessary API endpoints
use crate::io::api::{
    DatabasePersistence, EmptyField, Endpoint, RemoteResource, Resource, ResponseContent, TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS,
};
use crate::io::api::{DatabasePersistence, EmptyField, Endpoint, RemoteResource, ResponseContent, TreeEntryType, ValueValidator, INCLUDED_ENDPOINTS};
use crate::io::config::{RunnerStatus, RunnerType};
use crate::io::database::schema::{ProgrammingLanguageRow, Table};
use crate::io::database::{Database, Operations};
@@ -377,6 +375,9 @@ pub struct Options {
    pub internal_identifier: Option<String>,
    /// Request body payload
    pub body: Option<String>,
    /// GitLab domain (defaults to gitlab.com)
    #[builder(default = String::from("gitlab.com"))]
    pub domain: String,
}
/// GitLab API response for runner details
/// ### Example JSON response
@@ -575,6 +576,7 @@ impl Options {
    /// - `CI_JOB_TOKEN` -> `token` (defaults to empty string when unset)
    /// - `CI_PROJECT_ID` -> `identifier`
    /// - `CI_MERGE_REQUEST_IID` -> `internal_identifier`
    /// - `CI_SERVER_HOST` -> `domain` (defaults to gitlab.com when unset)
    ///
    /// See <https://docs.gitlab.com/ci/variables/predefined_variables> for more information on available GitLab CI environment variables
    pub fn from_env() -> Self {
@@ -583,6 +585,7 @@ impl Options {
            token: var("CI_JOB_TOKEN").unwrap_or_default(),
            identifier: var("CI_PROJECT_ID").ok(),
            internal_identifier: var("CI_MERGE_REQUEST_IID").ok(),
            domain: var("CI_SERVER_HOST").unwrap_or_else(|_| "gitlab.com".to_string()),
            body: None,
        }
    }
@@ -600,6 +603,13 @@ impl Options {
            ..self
        }
    }
    /// Return a copy of options with GitLab domain set
    pub fn with_domain(self, domain: impl Into<String>) -> Self {
        Self {
            domain: domain.into(),
            ..self
        }
    }
}
impl From<ProgrammingLanguageMetadata> for ProgrammingLanguageRow {
    fn from(value: ProgrammingLanguageMetadata) -> Self {
@@ -753,11 +763,12 @@ impl ValueValidator for PaginationField {
}
/// Get descendant groups of a group by identifier
pub async fn groups(options: Options) -> ApiResult<GroupsResponse> {
    let name = "GitLab";
    let action = "groups";
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let Options { token, identifier, .. } = options;
    let Options {
        token, identifier, domain, ..
    } = options;
    match Endpoint::from_template("gitlab").map(|e| e.with_domain(&domain)) {
        | Ok(endpoint) => {
            let params = vec![
                param!(Header, "PRIVATE-TOKEN", &token),
                param!(TemplateValue, "identifier", &identifier.unwrap_or_default()),
@@ -767,7 +778,7 @@ pub async fn groups(options: Options) -> ApiResult<GroupsResponse> {
            let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await;
            endpoint.handle::<GroupsResponse>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
        | Err(why) => Err(why),
    }
}
/// Download programming language metadata from GitLab linguist source file
@@ -802,16 +813,16 @@ pub async fn languages() -> ApiResult<ProgrammingLanguagesResponse> {
///
/// See <https://docs.gitlab.com/api/notes/#create-a-merge-request-note> for more information on this API endpoint and required parameters
pub async fn merge_request_note(options: Options) -> ApiResult<MergeRequestNoteResponse> {
    let name = "GitLab";
    let action = "merge-request-note";
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
    let Options {
        token,
        identifier,
        internal_identifier,
        body,
        domain,
    } = options;
    match Endpoint::from_template("gitlab").map(|e| e.with_domain(&domain)) {
        | Ok(endpoint) => {
            let body = body.unwrap_or_default();
            let params = vec![
                param!(Header, "PRIVATE-TOKEN", &token),
@@ -822,16 +833,17 @@ pub async fn merge_request_note(options: Options) -> ApiResult<MergeRequestNoteR
            let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await;
            endpoint.handle::<MergeRequestNoteResponse>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
        | Err(why) => Err(why),
    }
}
/// Get runner details by identifier
pub async fn runner(options: Options) -> ApiResult<RunnerDetails> {
    let name = "GitLab";
    let action = "runners";
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let Options { token, identifier, .. } = options;
    let Options {
        token, identifier, domain, ..
    } = options;
    match Endpoint::from_template("gitlab").map(|e| e.with_domain(&domain)) {
        | Ok(endpoint) => {
            let params = vec![
                param!(Header, "PRIVATE-TOKEN", &token),
                param!(TemplateValue, "identifier", &identifier.unwrap_or_default()),
@@ -839,38 +851,32 @@ pub async fn runner(options: Options) -> ApiResult<RunnerDetails> {
            let response = endpoint.invoke(action, Some(params)).await;
            endpoint.handle::<RunnerDetails>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
        | Err(why) => Err(why),
    }
}
/// Get runners visible to user associated with given token
pub async fn runners(options: Options) -> ApiResult<RunnersResponse> {
    let name = "GitLab";
    let action = "runners";
    match INCLUDED_ENDPOINTS.find_by_name(name) {
        | Some(endpoint) => {
            let params = vec![param!(Header, "PRIVATE-TOKEN", &options.token)];
    let Options { token, domain, .. } = options;
    match Endpoint::from_template("gitlab").map(|e| e.with_domain(&domain)) {
        | Ok(endpoint) => {
            let params = vec![param!(Header, "PRIVATE-TOKEN", &token)];
            let response = endpoint.invoke_with::<PaginationField, EmptyField>(action, Some(params)).await;
            endpoint.handle::<RunnersResponse>(response)
        }
        | None => Err(eyre!("{name} API endpoint not found")),
    }
        | Err(why) => Err(why),
    }
/// Construct an [`Endpoint`] for any GitLab host with the repository tree resource registered
pub(crate) fn endpoint_for(host: impl Into<String>) -> Endpoint {
    let tree = Resource::init()
        .name("tree")
        .method("get")
        .template("{{ base }}/projects/{{ identifier }}/repository/tree{{ query }}")
        .build();
    Endpoint::at(host).root("api/v4").resources(vec![tree]).build()
}

/// Fetch one page of repository tree blob paths for a GitLab project
pub(crate) async fn tree_paths(
    endpoint: &Endpoint,
    host: impl Into<String>,
    identifier: impl Into<String>,
    directory: impl Into<String>,
    page: u32,
) -> ApiResult<Vec<String>> {
    match Endpoint::from_template("gitlab").map(|e| e.with_domain(host.into())) {
        | Ok(endpoint) => {
            let params = vec![
                param!(TemplateValue, "identifier", &identifier.into()),
                param!(KeyValuePair, "per_page", "100"),
@@ -883,22 +889,15 @@ pub(crate) async fn tree_paths(
                .handle::<TreeResponse>(response)
                .map(|entries| entries.into_iter().filter(|e| e.is_blob()).map(|e| e.path()).collect())
        }
        | Err(why) => Err(why),
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::io::api::IntoBody;
    use core::iter::FromIterator;

    #[test]
    fn test_endpoint_for_base_url() {
        let endpoint = endpoint_for("code.ornl.gov");
        assert_eq!(endpoint.base(), "https://code.ornl.gov/api/v4");
    }
    #[test]
    fn test_endpoint_for_has_tree_resource() {
        let endpoint = endpoint_for("code.ornl.gov");
        assert!(endpoint.resources.iter().any(|r| r.name == "tree"));
    }
    #[test]
    fn test_add_comment_payload_uses_body_field() {
        let payload = vec![param!(Body, "body", "This is a test comment!!!")].into_body();
+20 −0
Original line number Diff line number Diff line
@@ -250,7 +250,27 @@ impl Endpoint {
        let root = root.as_ref().map_or(String::new(), |root| format!("/{root}"));
        format!("{scheme}://{domain}{port}{root}")
    }
    /// Create a new endpoint with a custom domain while preserving all other properties
    pub fn with_domain(&self, domain: impl Into<String>) -> Self {
        Self {
            domain: domain.into(),
            ..self.clone()
        }
    }
    /// Find an endpoint template by name from [`INCLUDED_ENDPOINTS`]
    ///
    /// # Example
    /// ```ignore
    /// let endpoint = Endpoint::from_template("gitlab")?.with_domain("my-gitlab.example.com");
    /// ```
    pub fn from_template(name: impl Into<String>) -> ApiResult<Self> {
        let endpoint_name = name.into();
        INCLUDED_ENDPOINTS
            .find_by_name(&endpoint_name)
            .ok_or_else(|| eyre!("Endpoint template '{endpoint_name}' not found in application configuration"))
    }
}

impl Searchable<Endpoint> for Vec<Endpoint> {
    fn find_by_name(&self, value: impl Into<String>) -> Option<Endpoint> {
        let name = value.into();
Loading