1 /** 2 * Synchronous helper to access the GitHub API 3 * 4 * License: 5 * This Source Code Form is subject to the terms of 6 * the Mozilla Public License, v. 2.0. If a copy of 7 * the MPL was not distributed with this file, You 8 * can obtain one at http://mozilla.org/MPL/2.0/. 9 * 10 * Authors: 11 * Vladimir Panteleev <ae@cy.md> 12 */ 13 14 module ae.net.github.rest; 15 16 package(ae): 17 18 import std.algorithm.searching; 19 import std.conv; 20 import std..string; 21 import std.utf; 22 23 import ae.net.http.common; 24 import ae.net.ietf.headers; 25 import ae.net.ietf.url; 26 import ae.sys.data; 27 import ae.sys.log; 28 import ae.sys.net; 29 import ae.utils.json; 30 import ae.utils.meta; 31 32 struct GitHub 33 { 34 string token; 35 36 void delegate(string) log; 37 38 struct CacheEntry 39 { 40 string[string] headers; 41 string data; 42 } 43 interface ICache 44 { 45 string get(string key); 46 void put(string key, string value); 47 } 48 static class NoCache : ICache 49 { 50 string get(string key) { return null; } 51 void put(string key, string value) {} 52 } 53 ICache cache = new NoCache; 54 55 struct Result 56 { 57 string[string] headers; 58 string data; 59 } 60 61 Result query(string url) 62 { 63 auto request = new HttpRequest; 64 request.resource = url; 65 if (token) 66 request.headers["Authorization"] = "token " ~ token; 67 68 auto cacheKey = url; 69 70 CacheEntry cacheEntry; 71 auto cacheEntryStr = cache.get(cacheKey); 72 if (cacheEntryStr) 73 { 74 cacheEntry = cacheEntryStr.jsonParse!CacheEntry(); 75 auto cacheHeaders = Headers(cacheEntry.headers); 76 77 if (auto p = "ETag" in cacheHeaders) 78 request.headers["If-None-Match"] = *p; 79 if (auto p = "Last-Modified" in cacheHeaders) 80 request.headers["If-Modified-Since"] = *p; 81 } 82 83 if (log) log("Getting URL " ~ url); 84 85 auto response = net.httpRequest(request); 86 while (true) // Redirect loop 87 { 88 if (response.status == HttpStatusCode.NotModified) 89 { 90 if (log) log(" > Cache hit"); 91 return Result(cacheEntry.headers, cacheEntry.data); 92 } 93 else 94 if (response.status == HttpStatusCode.OK) 95 { 96 if (log) log(" > Cache miss; ratelimit: %s/%s".format( 97 response.headers.get("X-Ratelimit-Remaining", "?"), 98 response.headers.get("X-Ratelimit-Limit", "?"), 99 )); 100 scope(failure) if (log) log(response.headers.text); 101 auto headers = response.headers.to!(string[string]); 102 auto data = (cast(char[])response.getContent().contents).idup; 103 cacheEntry.headers = headers; 104 cacheEntry.data = data; 105 cache.put(cacheKey, toJson(cacheEntry)); 106 return Result(headers, data); 107 } 108 else 109 if (response.status >= 300 && response.status < 400 && "Location" in response.headers) 110 { 111 auto location = response.headers["Location"]; 112 if (log) log(" > Redirect: " ~ location); 113 request.resource = applyRelativeURL(request.url, location); 114 if (response.status == HttpStatusCode.SeeOther) 115 { 116 request.method = "GET"; 117 request.data = null; 118 } 119 response = net.httpRequest(request); 120 } 121 else 122 throw new Exception("Error with URL " ~ url ~ ": " ~ text(response.status)); 123 } 124 } 125 126 static import std.json; 127 128 std.json.JSONValue[] pagedQuery(string url) 129 { 130 import std.json : JSONValue, parseJSON; 131 132 JSONValue[] result; 133 while (true) 134 { 135 auto page = query(url); 136 result ~= page.data.parseJSON().array; 137 auto links = page.headers.get("Link", null).I!parseLinks(); 138 if ("next" in links) 139 url = links["next"]; 140 else 141 break; 142 } 143 return result; 144 } 145 146 /// Parse a "Link" header. 147 private static string[string] parseLinks(string s) 148 { 149 string[string] result; 150 auto items = s.split(", "); // Hacky but should never occur inside an URL or "rel" value 151 foreach (item; items) 152 { 153 auto parts = item.split("; "); // ditto 154 string url; string[string] args; 155 foreach (part; parts) 156 { 157 if (part.startsWith("<") && part.endsWith(">")) 158 url = part[1..$-1]; 159 else 160 { 161 auto ps = part.findSplit("="); 162 auto key = ps[0]; 163 auto value = ps[2]; 164 if (value.startsWith('"') && value.endsWith('"')) 165 value = value[1..$-1]; 166 args[key] = value; 167 } 168 } 169 result[args.get("rel", null)] = url; 170 } 171 return result; 172 } 173 174 unittest 175 { 176 auto header = `<https://api.github.com/repositories/1257070/pulls?per_page=100&page=2>; rel="next", ` ~ 177 `<https://api.github.com/repositories/1257070/pulls?per_page=100&page=3>; rel="last"`; 178 assert(parseLinks(header) == [ 179 "next" : "https://api.github.com/repositories/1257070/pulls?per_page=100&page=2", 180 "last" : "https://api.github.com/repositories/1257070/pulls?per_page=100&page=3", 181 ]); 182 } 183 184 string post(string url, Data jsonData) 185 { 186 auto request = new HttpRequest; 187 request.resource = url; 188 request.method = "POST"; 189 if (token) 190 request.headers["Authorization"] = "token " ~ token; 191 request.headers["Content-Type"] = "application/json"; 192 request.data = DataVec(jsonData); 193 194 auto response = net.httpRequest(request); 195 string result = cast(string)response.data.joinToHeap; 196 validate(result); 197 return result; 198 } 199 }