Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 1 | // Copyright 2016 Google Inc. All rights reserved. |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | // Package gitiles is a client library for the Gitiles source viewer. |
| 16 | package gitiles |
| 17 | |
| 18 | import ( |
| 19 | "bytes" |
| 20 | "encoding/base64" |
| 21 | "encoding/json" |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 22 | "flag" |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 23 | "fmt" |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 24 | "io" |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 25 | "io/ioutil" |
| 26 | "log" |
| 27 | "net/http" |
| 28 | "net/url" |
| 29 | "path" |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 30 | "strings" |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 31 | |
Han-Wen Nienhuys | 0213eb6 | 2016-07-19 13:52:51 +0200 | [diff] [blame] | 32 | "github.com/google/slothfs/cookie" |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 33 | "golang.org/x/net/context" |
| 34 | "golang.org/x/time/rate" |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 35 | ) |
| 36 | |
| 37 | // Service is a client for the Gitiles JSON interface. |
| 38 | type Service struct { |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 39 | limiter *rate.Limiter |
| 40 | addr url.URL |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 41 | client http.Client |
| 42 | agent string |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 43 | jar http.CookieJar |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 44 | debug bool |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 45 | } |
| 46 | |
| 47 | // Addr returns the address of the gitiles service. |
| 48 | func (s *Service) Addr() string { |
| 49 | return s.addr.String() |
| 50 | } |
| 51 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 52 | // Options configures the the Gitiles service. |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 53 | type Options struct { |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 54 | // A URL for the Gitiles service. |
| 55 | Address string |
| 56 | |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 57 | BurstQPS int |
| 58 | SustainedQPS float64 |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 59 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 60 | // Path to a Netscape/Mozilla style cookie file. |
| 61 | CookieJar string |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 62 | |
| 63 | // UserAgent defines how we present ourself to the server. |
| 64 | UserAgent string |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 65 | |
Billy Lynch | ecdd255 | 2017-01-12 18:31:36 -0500 | [diff] [blame] | 66 | // HTTPClient allows callers to present their own http.Client instead of the default. |
| 67 | HTTPClient http.Client |
| 68 | |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 69 | Debug bool |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 70 | } |
| 71 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 72 | var defaultOptions Options |
Han-Wen Nienhuys | 0213eb6 | 2016-07-19 13:52:51 +0200 | [diff] [blame] | 73 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 74 | // DefineFlags sets up standard command line flags, and returns the |
| 75 | // options struct in which the values are put. |
| 76 | func DefineFlags() *Options { |
Han-Wen Nienhuys | fa03949 | 2016-08-10 12:42:29 +0200 | [diff] [blame] | 77 | flag.StringVar(&defaultOptions.Address, "gitiles_url", "https://android.googlesource.com", "Set the URL of the Gitiles service.") |
| 78 | flag.StringVar(&defaultOptions.CookieJar, "gitiles_cookies", "", "Set path to cURL-style cookie jar file.") |
| 79 | flag.StringVar(&defaultOptions.UserAgent, "gitiles_agent", "slothfs", "Set the User-Agent string to report to Gitiles.") |
| 80 | flag.Float64Var(&defaultOptions.SustainedQPS, "gitiles_qps", 4, "Set the maximum QPS to send to Gitiles.") |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 81 | flag.BoolVar(&defaultOptions.Debug, "gitiles_debug", false, "Print URLs as they are fetched.") |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 82 | return &defaultOptions |
Han-Wen Nienhuys | 0213eb6 | 2016-07-19 13:52:51 +0200 | [diff] [blame] | 83 | } |
| 84 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 85 | // NewService returns a new Gitiles JSON client. |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 86 | func NewService(opts Options) (*Service, error) { |
| 87 | var jar http.CookieJar |
| 88 | if nm := opts.CookieJar; nm != "" { |
| 89 | var err error |
| 90 | jar, err = cookie.NewJar(nm) |
| 91 | if err != nil { |
| 92 | return nil, err |
| 93 | } |
| 94 | if err := cookie.WatchJar(jar, nm); err != nil { |
| 95 | return nil, err |
| 96 | } |
| 97 | } |
| 98 | |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 99 | if opts.SustainedQPS == 0.0 { |
Han-Wen Nienhuys | 965dcb3 | 2016-08-01 14:09:41 +0200 | [diff] [blame] | 100 | opts.SustainedQPS = 4 |
| 101 | } |
| 102 | if opts.BurstQPS == 0 { |
| 103 | opts.BurstQPS = int(10.0 * opts.SustainedQPS) |
| 104 | } else if float64(opts.BurstQPS) < opts.SustainedQPS { |
| 105 | opts.BurstQPS = int(opts.SustainedQPS) + 1 |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 106 | } |
| 107 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 108 | url, err := url.Parse(opts.Address) |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 109 | if err != nil { |
| 110 | return nil, err |
| 111 | } |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 112 | s := &Service{ |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 113 | limiter: rate.NewLimiter(rate.Limit(opts.SustainedQPS), opts.BurstQPS), |
| 114 | addr: *url, |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 115 | agent: opts.UserAgent, |
Billy Lynch | ecdd255 | 2017-01-12 18:31:36 -0500 | [diff] [blame] | 116 | client: opts.HTTPClient, |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 117 | } |
| 118 | |
Han-Wen Nienhuys | 21c9742 | 2016-07-22 15:23:18 +0200 | [diff] [blame] | 119 | s.client.Jar = jar |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 120 | s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { |
| 121 | req.Header.Set("User-Agent", s.agent) |
| 122 | return nil |
| 123 | } |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 124 | s.debug = opts.Debug |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 125 | return s, nil |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 126 | } |
| 127 | |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 128 | func (s *Service) stream(u *url.URL) (*http.Response, error) { |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 129 | ctx := context.Background() |
| 130 | |
| 131 | if err := s.limiter.Wait(ctx); err != nil { |
| 132 | return nil, err |
| 133 | } |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 134 | req, err := http.NewRequest("GET", u.String(), nil) |
| 135 | if err != nil { |
| 136 | return nil, err |
| 137 | } |
| 138 | req.Header.Add("User-Agent", s.agent) |
| 139 | resp, err := s.client.Do(req) |
Han-Wen Nienhuys | abc8d15 | 2016-07-04 17:26:59 +0200 | [diff] [blame] | 140 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 141 | if err != nil { |
| 142 | return nil, err |
| 143 | } |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 144 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 145 | if resp.StatusCode != 200 { |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 146 | resp.Body.Close() |
Han-Wen Nienhuys | 39ace57 | 2016-06-02 18:30:51 +0200 | [diff] [blame] | 147 | return nil, fmt.Errorf("%s: %s", u.String(), resp.Status) |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 148 | } |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 149 | |
| 150 | if s.debug { |
| 151 | log.Printf("%s %s: %d", req.Method, req.URL, resp.StatusCode) |
| 152 | } |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 153 | if got := resp.Request.URL.String(); got != u.String() { |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 154 | resp.Body.Close() |
Han-Wen Nienhuys | 3677ba9 | 2016-07-07 18:55:51 +0200 | [diff] [blame] | 155 | // We accept redirects, but only for authentication. |
| 156 | // If we get a 200 from a different page than we |
| 157 | // requested, it's probably some sort of login page. |
| 158 | return nil, fmt.Errorf("got URL %s, want %s", got, u.String()) |
| 159 | } |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 160 | |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 161 | return resp, nil |
| 162 | } |
| 163 | |
| 164 | func (s *Service) get(u *url.URL) ([]byte, error) { |
| 165 | resp, err := s.stream(u) |
| 166 | if err != nil { |
| 167 | return nil, err |
| 168 | } |
| 169 | |
| 170 | defer resp.Body.Close() |
| 171 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 172 | c, err := ioutil.ReadAll(resp.Body) |
| 173 | if err != nil { |
| 174 | return nil, err |
| 175 | } |
Han-Wen Nienhuys | ae6d11c | 2016-07-11 13:50:45 +0200 | [diff] [blame] | 176 | |
Han-Wen Nienhuys | 163ec59 | 2016-07-11 14:34:17 +0200 | [diff] [blame] | 177 | if resp.Header.Get("Content-Type") == "text/plain; charset=UTF-8" { |
Han-Wen Nienhuys | ae6d11c | 2016-07-11 13:50:45 +0200 | [diff] [blame] | 178 | out := make([]byte, base64.StdEncoding.DecodedLen(len(c))) |
| 179 | n, err := base64.StdEncoding.Decode(out, c) |
| 180 | return out[:n], err |
| 181 | } |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 182 | return c, nil |
| 183 | } |
| 184 | |
| 185 | var xssTag = []byte(")]}'\n") |
| 186 | |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 187 | func (s *Service) getJSON(u *url.URL, dest interface{}) error { |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 188 | c, err := s.get(u) |
| 189 | if err != nil { |
| 190 | return err |
| 191 | } |
| 192 | |
| 193 | if !bytes.HasPrefix(c, xssTag) { |
| 194 | return fmt.Errorf("Gitiles JSON %s missing XSS tag: %q", u, c) |
| 195 | } |
| 196 | c = c[len(xssTag):] |
| 197 | |
| 198 | err = json.Unmarshal(c, dest) |
| 199 | if err != nil { |
| 200 | err = fmt.Errorf("Unmarshal(%s): %v", u, err) |
| 201 | } |
| 202 | return err |
| 203 | } |
| 204 | |
| 205 | // List retrieves the list of projects. |
Han-Wen Nienhuys | 8b96081 | 2016-07-13 18:15:57 +0200 | [diff] [blame] | 206 | func (s *Service) List(branches []string) (map[string]*Project, error) { |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 207 | listURL := s.addr |
| 208 | listURL.RawQuery = "format=JSON" |
Han-Wen Nienhuys | 8b96081 | 2016-07-13 18:15:57 +0200 | [diff] [blame] | 209 | for _, b := range branches { |
| 210 | listURL.RawQuery += "&b=" + b |
| 211 | } |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 212 | |
| 213 | projects := map[string]*Project{} |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 214 | err := s.getJSON(&listURL, &projects) |
Han-Wen Nienhuys | d42875c | 2016-11-30 19:07:27 +0100 | [diff] [blame] | 215 | for k, v := range projects { |
| 216 | if k != v.Name { |
| 217 | return nil, fmt.Errorf("gitiles: key %q had project name %q", k, v.Name) |
| 218 | } |
| 219 | } |
| 220 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 221 | return projects, err |
| 222 | } |
| 223 | |
Han-Wen Nienhuys | a501c04 | 2016-07-21 16:28:49 +0200 | [diff] [blame] | 224 | // NewRepoService creates a service for a specific repository on a Gitiles server. |
Han-Wen Nienhuys | 39ace57 | 2016-06-02 18:30:51 +0200 | [diff] [blame] | 225 | func (s *Service) NewRepoService(name string) *RepoService { |
| 226 | return &RepoService{ |
| 227 | Name: name, |
| 228 | service: s, |
| 229 | } |
| 230 | } |
| 231 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 232 | // RepoService is a JSON client for the functionality of a specific |
| 233 | // respository. |
| 234 | type RepoService struct { |
| 235 | Name string |
| 236 | service *Service |
| 237 | } |
| 238 | |
Han-Wen Nienhuys | 27dde40 | 2016-06-03 17:44:33 +0200 | [diff] [blame] | 239 | // Get retrieves a single project. |
| 240 | func (s *RepoService) Get() (*Project, error) { |
| 241 | jsonURL := s.service.addr |
| 242 | jsonURL.Path = path.Join(jsonURL.Path, s.Name) |
| 243 | jsonURL.RawQuery = "format=JSON" |
| 244 | |
| 245 | var p Project |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 246 | err := s.service.getJSON(&jsonURL, &p) |
Han-Wen Nienhuys | 27dde40 | 2016-06-03 17:44:33 +0200 | [diff] [blame] | 247 | return &p, err |
| 248 | } |
| 249 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 250 | // GetBlob fetches a blob. |
| 251 | func (s *RepoService) GetBlob(branch, filename string) ([]byte, error) { |
| 252 | blobURL := s.service.addr |
| 253 | |
| 254 | blobURL.Path = path.Join(blobURL.Path, s.Name, "+show", branch, filename) |
| 255 | blobURL.RawQuery = "format=TEXT" |
| 256 | |
| 257 | // TODO(hanwen): invent a more structured mechanism for logging. |
| 258 | log.Println(blobURL.String()) |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 259 | return s.service.get(&blobURL) |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 260 | } |
| 261 | |
Han-Wen Nienhuys | f4bca23 | 2017-12-04 10:11:59 +0100 | [diff] [blame^] | 262 | // Archive formats for +archive. JGit also supports some shorthands. |
| 263 | const ( |
| 264 | ArchiveTbz = "tar.bz2" |
| 265 | ArchiveTgz = "tar.gz" |
| 266 | ArchiveTar = "tar" |
| 267 | ArchiveTxz = "tar.xz" |
| 268 | |
| 269 | // the Gitiles source code claims .tar.xz and .tar are |
| 270 | // supported, but googlesource.com doesn't support it, |
| 271 | // apparently. In addition, JGit provides ZipFormat, but |
| 272 | // gitiles doesn't support it. |
| 273 | ) |
| 274 | |
| 275 | // GetArchive downloads an archive of the project. Format is one |
| 276 | // ArchivXxx formats. dirPrefix, if given, restricts to the given |
| 277 | // subpath, and strips the path prefix from the files in the resulting |
| 278 | // tar archive. revision is a git revision, either a branch/tag name |
| 279 | // ("master") or a hex commit SHA1. |
| 280 | func (s *RepoService) GetArchive(revision, dirPrefix, format string) (io.ReadCloser, error) { |
| 281 | u := s.service.addr |
| 282 | u.Path = path.Join(u.Path, s.Name, "+archive", revision) |
| 283 | if dirPrefix != "" { |
| 284 | u.Path = path.Join(u.Path, dirPrefix) |
| 285 | } |
| 286 | u.Path += "." + format |
| 287 | resp, err := s.service.stream(&u) |
| 288 | if err != nil { |
| 289 | return nil, err |
| 290 | } |
| 291 | return resp.Body, err |
| 292 | } |
| 293 | |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 294 | // GetTree fetches a tree. The dir argument may not point to a |
| 295 | // blob. If recursive is given, the server recursively expands the |
| 296 | // tree. |
| 297 | func (s *RepoService) GetTree(branch, dir string, recursive bool) (*Tree, error) { |
| 298 | jsonURL := s.service.addr |
| 299 | jsonURL.Path = path.Join(jsonURL.Path, s.Name, "+", branch, dir) |
Han-Wen Nienhuys | 1e2b8af | 2016-11-23 13:58:25 +0100 | [diff] [blame] | 300 | if !strings.HasSuffix(jsonURL.Path, "/") { |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 301 | jsonURL.Path += "/" |
| 302 | } |
| 303 | jsonURL.RawQuery = "format=JSON&long=1" |
| 304 | |
| 305 | if recursive { |
| 306 | jsonURL.RawQuery += "&recursive=1" |
| 307 | } |
| 308 | |
| 309 | var tree Tree |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 310 | err := s.service.getJSON(&jsonURL, &tree) |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 311 | return &tree, err |
| 312 | } |
| 313 | |
| 314 | // GetCommit gets the data of a commit in a branch. |
| 315 | func (s *RepoService) GetCommit(branch string) (*Commit, error) { |
| 316 | jsonURL := s.service.addr |
| 317 | jsonURL.Path = path.Join(jsonURL.Path, s.Name, "+", branch) |
| 318 | jsonURL.RawQuery = "format=JSON" |
| 319 | |
| 320 | var c Commit |
Han-Wen Nienhuys | f065f14 | 2016-07-11 14:03:57 +0200 | [diff] [blame] | 321 | err := s.service.getJSON(&jsonURL, &c) |
Han-Wen Nienhuys | 4bf7fcc | 2016-06-02 15:03:59 +0200 | [diff] [blame] | 322 | return &c, err |
| 323 | } |