Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pagination support #10

Merged
merged 12 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ jobs:
build:
name: build-linux
runs-on: ubuntu-latest
container: tipibuild/tipi-ubuntu
container:
image: tipibuild/tipi-ubuntu
options: --user root
env:
GH_USER: nxxmgh
GH_PASS: ${{ secrets.USER_PAT_NXXMGH_TOKEN_FOR_GH }}
GH_RELEASE_TEST_REPO_OWNER: tipibuild
GH_RELEASE_TEST_REPO_NAME: test-gh-client-release
steps:
- name: checkout
uses: actions/checkout@v2
Expand Down
133 changes: 130 additions & 3 deletions .tipi/deps
Original file line number Diff line number Diff line change
@@ -1,5 +1,132 @@
{
"nxxm/xxhr" : { "@" : "feature/move-to-native-tipi-deps" }
, "cpp-pre/json" : { "@" : "feature/move-to-native-tipi-deps" }
, "cpp-pre/type_traits": {}
"cpp-pre/json": {
"@": "feature/move-to-native-tipi-deps",
"requires": {
"boostorg/boost": {
"@": "boost-1.80.0",
"opts": "set(BOOST_INCLUDE_LIBRARIES system filesystem uuid)",
"packages": [
"boost_system",
"boost_filesystem",
"boost_uuid"
],
"targets": [
"Boost::system",
"Boost::filesystem",
"Boost::uuid"
],
"u": true
},
"nlohmann/json": {
"@": "v3.11.2",
"u": false,
"x": [
"benchmarks",
"/tests",
"/docs",
"/tools"
]
}
},
"u": false
},
"cpp-pre/type_traits": {
"@": "v2.0.0",
"u": false,
"x": [
"/test"
]
},
"nxxm/xxhr": {
"@": ":faa17ac3547ad194d916ea69ac3fd479202629da",
"requires": {
"aantron/better-enums": {
"@": "0.11.1",
"u": false,
"x": [
"/example",
"/script",
"/doc",
"/test"
]
},
"boostorg/boost": {
"@": "boost-1.80.0",
"opts": "set(BOOST_INCLUDE_LIBRARIES system filesystem uuid)",
"packages": [
"boost_system",
"boost_filesystem",
"boost_uuid"
],
"targets": [
"Boost::system",
"Boost::filesystem",
"Boost::uuid"
],
"u": true
},
"nxxm/curl": {
"@": ":eee4ae62ee24aec9c7f8948fd8670a5e80c2cf83",
"opts": "set(BUILD_CURL_TESTS OFF) \nset(BUILD_CURL_EXE ON) \nset(CMAKE_USE_OPENSSL ON) \nset(CMAKE_USE_LIBSSH2 OFF) \nset(BUILD_TESTING OFF)",
"packages": [
"CURL"
],
"requires": {
"hunter-packages/c-ares": {
"@": "v1.14.0-p0",
"packages": [
"c-ares"
],
"targets": [
"c-ares::cares"
],
"u": true
},
"hunter-packages/zlib": {
"@": "v1.2.11-p1",
"packages": [
"ZLIB"
],
"targets": [
"ZLIB::zlib"
],
"u": true
},
"nxxm/boringssl": {
"@": ":358175c062c3a3964d4734df4b122e6af851def0",
"u": true,
"packages": [
"OpenSSL",
"BoringSSL"
],
"targets": [
"OpenSSL::SSL",
"OpenSSL::Crypto",
"BoringSSL::decrepit"
],
"find_mode": " "
}
},
"targets": [
"CURL::libcurl"
],
"u": true
}
}
},
"boostorg/boost": {
"@": "boost-1.80.0",
"opts": "set(BOOST_INCLUDE_LIBRARIES system filesystem uuid)",
"packages": [
"boost_system",
"boost_filesystem",
"boost_uuid"
],
"targets": [
"Boost::system",
"Boost::filesystem",
"Boost::uuid"
],
"u": true
}
}
44 changes: 33 additions & 11 deletions gh/branches.hxx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <xxhr/xxhr.hpp>

#include <gh/auth.hxx>
#include <gh/pagination.hxx>

namespace gh::repos {

Expand Down Expand Up @@ -43,6 +44,8 @@ BOOST_FUSION_ADAPT_STRUCT(gh::repos::branch_t, name, commit, protection_url, is_

namespace gh {

using namespace std::string_literals;

/**
* \brief list github branches, passing them to the result_handler as std::vector<branch_t>.
* See https://developer.github.com/v3/repos/branches/#list-branches
Expand All @@ -53,11 +56,25 @@ namespace gh {
* \param result_handler Callable with signature : `func(std::vector<branch_t>)`
*/
inline void list_branches(std::string owner, std::string repos, std::function<void(repos::branches&&)>&& result_handler,
std::optional<auth> auth = std::nullopt) {
std::optional<auth> auth = std::nullopt,
const std::string& api_endpoint = "https://api.github.com"s) {

using namespace xxhr;
auto url = "https://api.github.com/repos/"s + owner + "/" + repos + "/branches"s;
auto response_handler = [&](auto&& resp) {
std::function<void(xxhr::Response&&)> response_handler;
repos::branches all_branches;

auto do_request = [&](std::string url) {
if (auth) {
GET(url,
Authentication{auth->user, auth->pass},
on_response = response_handler
);
} else {
GET(url, on_response = response_handler);
}
};

response_handler = [&](auto&& resp) {
if ( (!resp.error) && (resp.status_code == 200) ) {

auto mapper = [&](nlohmann::json &jdoc){
Expand All @@ -72,17 +89,22 @@ namespace gh {
}
};

result_handler(pre::json::from_json<repos::branches>(resp.text, mapper));
auto page = pre::json::from_json<repos::branches>(resp.text, mapper);
all_branches.insert(all_branches.end(), page.begin(), page.end());

auto next_page = gh::detail::pagination::get_next_page_url(resp);
if(next_page) {
do_request(next_page.value());
}
else {
result_handler(std::move(all_branches));
}
} else {
throw std::runtime_error(url + " is not responding");
throw std::runtime_error(resp.url + " failed with error: " + std::string(resp.error) + " - " + resp.text);
}
};

if (auth) {
GET(url, Authentication{auth->user, auth->pass},
on_response = response_handler);
} else {
GET(url, on_response = response_handler);
}
auto url = api_endpoint + "/repos/" + owner + "/" + repos + "/branches?" + gh::detail::pagination::get_per_page_query_string();
do_request(url);
}
}
48 changes: 29 additions & 19 deletions gh/issues.hxx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

#include <gh/owner.hxx>
#include <gh/auth.hxx>
#include <gh/pagination.hxx>

namespace gh::repos {

Expand Down Expand Up @@ -67,7 +68,7 @@ namespace gh::repos {
uint64_t number;
std::string state;
std::string title;
std::string body;
std::optional<std::string> body;
owner_t user;

std::vector<label_t> labels;
Expand Down Expand Up @@ -123,31 +124,40 @@ namespace gh {
const std::string& api_endpoint = "https://api.github.com"s ) {

using namespace xxhr;
auto url = api_endpoint + "/repos/"s + owner + "/" + repository + "/issues";
std::function<void(xxhr::Response&&)> response_handler;
repos::issues all_issues;

auto do_request = [&](std::string url) {
if (auth) {
GET(url,
Authentication{auth->user, auth->pass},
on_response = response_handler);
} else {
GET(url, on_response = response_handler);
}
};

auto response_handler = [&](auto&& resp) {
response_handler = [&](auto&& resp) {
if ( (!resp.error) && (resp.status_code == 200) ) {
result_handler(pre::json::from_json<repos::issues>(resp.text));
auto page = pre::json::from_json<repos::issues>(resp.text);
all_issues.insert(all_issues.end(), page.begin(), page.end());

auto next_page = gh::detail::pagination::get_next_page_url(resp);

if(next_page) {
do_request(next_page.value());
}
else {
result_handler(std::move(all_issues));
}
} else {
throw std::runtime_error( "err : "s + std::string(resp.error) + "status: "s
+ std::to_string(resp.status_code) + " accessing : "s + url );
+ std::to_string(resp.status_code) + " accessing : "s + resp.url );
}
};


if (auth) {
GET(url,
Parameters{{"state", state}},
Authentication{auth->user, auth->pass},
on_response = response_handler);
} else {
GET(url,
Parameters{{"state", state}},
on_response = response_handler);
}



auto url = api_endpoint + "/repos/"s + owner + "/" + repository + "/issues?state=" + state + "&" + gh::detail::pagination::get_per_page_query_string();
do_request(url);
}

inline void get_issue(std::string owner, std::string repository, uint64_t issue_number,
Expand Down
83 changes: 83 additions & 0 deletions gh/pagination.hxx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#pragma once

#include <string>
#include <map>
#include <xxhr/xxhr.hpp>
#include <xxhr/util.hpp>
#include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/split.hpp>

namespace gh::detail::pagination {
using namespace std::string_literals;

const size_t MAX_PAGE_SIZE = 100;
const size_t MIN_PAGE_SIZE = 5;
const size_t DEFAULT_PAGE_SIZE = 30;

const std::string PAGE_LINK_FIRST = "first";
const std::string PAGE_LINK_LAST = "last";
const std::string PAGE_LINK_NEXT = "next";
const std::string PAGE_LINK_PREVIOUS = "prev";

const std::string HEADER_NAME_PAGE_LINKS = "link";

inline bool has_gh_pagination_links(const xxhr::Response &resp) {
return resp.header.find(HEADER_NAME_PAGE_LINKS) != resp.header.end();
}

inline std::map<std::string, std::string> parse_gh_pagination_header(std::string header_value) {
// as per doc https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28, the header comes as:
// link: <https://api.github.com/repositories/1300192/issues?page=2>; rel="prev", <https://api.github.com/repositories/1300192/issues?page=4>; rel="next", <https://api.github.com/repositories/1300192/issues?page=515>; rel="last", <https://api.github.com/repositories/1300192/issues?page=1>; rel="first"
std::map<std::string, std::string> result;

std::vector<std::string> link_parts;
boost::split(link_parts, header_value, boost::is_any_of(","));

for(auto lp : link_parts) {

size_t pos_semicolon = lp.find(';', 0);

std::string url_fragment = lp.substr(0, pos_semicolon); // should look like ' <https://api.github.com/repositories/1300192/issues?page=2>'
boost::trim_all_if(url_fragment, boost::is_any_of("<> "));


std::string rel_fragment = lp.substr(pos_semicolon + 1 /* skip the semicolon */);
boost::trim_all(rel_fragment); // should now look like 'rel="page-link-name"'

static const std::string rel_prefix = "rel=";
if(boost::starts_with(rel_fragment, rel_prefix)) {
rel_fragment = rel_fragment.substr(rel_prefix.length());
boost::trim_if(rel_fragment, boost::is_any_of("\""));
}
else {
throw std::runtime_error("Cannot parse malformed GH pagination header: link: "s + header_value);
}

result.insert({ rel_fragment, url_fragment });
}

return result;
}

//! \brief returns the next page URL or nullopt given a xxhr Response
inline std::optional<std::string> get_next_page_url(const xxhr::Response &resp) {

if(has_gh_pagination_links(resp)) {
auto links = parse_gh_pagination_header(resp.header.at(HEADER_NAME_PAGE_LINKS));
auto next_page_it = links.find(PAGE_LINK_NEXT);
if(next_page_it != links.end()) {
return next_page_it->second;
}
}

return std::nullopt;
}

const std::string QUERY_STRING_PER_PAGE_KEY = "per_page";

//! \brief get the query string fragment for paginated urls
inline std::string get_per_page_query_string(size_t per_page = MAX_PAGE_SIZE) {
return QUERY_STRING_PER_PAGE_KEY + "="s + std::to_string(per_page);
}

}
Loading
Loading