gnu: guile-simple-zmq: Update to 68bedb6.
[jackhill/guix/guix.git] / guix / swh.scm
CommitLineData
de2bfe90
LC
1;;; GNU Guix --- Functional package management for GNU
2;;; Copyright © 2018 Ludovic Courtès <ludo@gnu.org>
3;;;
4;;; This file is part of GNU Guix.
5;;;
6;;; GNU Guix is free software; you can redistribute it and/or modify it
7;;; under the terms of the GNU General Public License as published by
8;;; the Free Software Foundation; either version 3 of the License, or (at
9;;; your option) any later version.
10;;;
11;;; GNU Guix is distributed in the hope that it will be useful, but
12;;; WITHOUT ANY WARRANTY; without even the implied warranty of
13;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14;;; GNU General Public License for more details.
15;;;
16;;; You should have received a copy of the GNU General Public License
17;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
18
19(define-module (guix swh)
20 #:use-module (guix base16)
21 #:use-module (guix build utils)
22 #:use-module ((guix build syscalls) #:select (mkdtemp!))
23 #:use-module (web client)
24 #:use-module (web response)
25 #:use-module (json)
26 #:use-module (srfi srfi-1)
27 #:use-module (srfi srfi-9)
28 #:use-module (srfi srfi-11)
29 #:use-module (srfi srfi-19)
30 #:use-module (ice-9 match)
31 #:use-module (ice-9 regex)
32 #:use-module (ice-9 popen)
33 #:use-module ((ice-9 ftw) #:select (scandir))
34 #:export (origin?
35 origin-id
36 origin-type
37 origin-url
38 origin-visits
39 lookup-origin
40
41 visit?
42 visit-date
43 visit-origin
44 visit-url
45 visit-snapshot-url
46 visit-status
47 visit-number
48 visit-snapshot
49
50 branch?
51 branch-name
52 branch-target
53
54 release?
55 release-id
56 release-name
57 release-message
58 release-target
59
60 revision?
61 revision-id
62 revision-date
63 revision-directory
64 lookup-revision
65 lookup-origin-revision
66
67 content?
68 content-checksums
69 content-data-url
70 content-length
71 lookup-content
72
73 directory-entry?
74 directory-entry-name
75 directory-entry-type
76 directory-entry-checksums
77 directory-entry-length
78 directory-entry-permissions
79 lookup-directory
80 directory-entry-target
81
82 vault-reply?
83 vault-reply-id
84 vault-reply-fetch-url
85 vault-reply-object-id
86 vault-reply-object-type
87 vault-reply-progress-message
88 vault-reply-status
89 query-vault
90 request-cooking
91 vault-fetch
92
93 swh-download))
94
95;;; Commentary:
96;;;
97;;; This module provides bindings to the HTTP interface of Software Heritage.
98;;; It allows you to browse the archive, look up revisions (such as SHA1
99;;; commit IDs), "origins" (code hosting URLs), content (files), etc. See
100;;; <https://archive.softwareheritage.org/api/> for more information.
101;;;
102;;; The high-level 'swh-download' procedure allows you to download a Git
103;;; revision from Software Heritage, provided it is available.
104;;;
105;;; Code:
106
107(define %swh-base-url
108 ;; Presumably we won't need to change it.
109 "https://archive.softwareheritage.org")
110
111(define (swh-url path . rest)
112 (define url
113 (string-append %swh-base-url path
114 (string-join rest "/" 'prefix)))
115
116 ;; Ensure there's a trailing slash or we get a redirect.
117 (if (string-suffix? "/" url)
118 url
119 (string-append url "/")))
120
121(define-syntax-rule (define-json-reader json->record ctor spec ...)
122 "Define JSON->RECORD as a procedure that converts a JSON representation,
123read from a port, string, or hash table, into a record created by CTOR and
124following SPEC, a series of field specifications."
125 (define (json->record input)
126 (let ((table (cond ((port? input)
127 (json->scm input))
128 ((string? input)
129 (json-string->scm input))
130 ((hash-table? input)
131 input))))
132 (let-syntax ((extract-field (syntax-rules ()
133 ((_ table (field key json->value))
134 (json->value (hash-ref table key)))
135 ((_ table (field key))
136 (hash-ref table key))
137 ((_ table (field))
138 (hash-ref table
139 (symbol->string 'field))))))
140 (ctor (extract-field table spec) ...)))))
141
142(define-syntax-rule (define-json-mapping rtd ctor pred json->record
143 (field getter spec ...) ...)
144 "Define RTD as a record type with the given FIELDs and GETTERs, à la SRFI-9,
145and define JSON->RECORD as a conversion from JSON to a record of this type."
146 (begin
147 (define-record-type rtd
148 (ctor field ...)
149 pred
150 (field getter) ...)
151
152 (define-json-reader json->record ctor
153 (field spec ...) ...)))
154
155(define %date-regexp
156 ;; Match strings like "2014-11-17T22:09:38+01:00" or
157 ;; "2018-09-30T23:20:07.815449+00:00"".
158 (make-regexp "^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})((\\.[0-9]+)?)([+-][0-9]{2}):([0-9]{2})$"))
159
160(define (string->date* str)
161 "Return a SRFI-19 date parsed from STR, a date string as returned by
162Software Heritage."
163 ;; We can't use 'string->date' because of the timezone format: SWH returns
164 ;; "+01:00" when the '~z' template expects "+0100". So we roll our own!
165 (or (and=> (regexp-exec %date-regexp str)
166 (lambda (match)
167 (define (ref n)
168 (string->number (match:substring match n)))
169
170 (make-date (let ((ns (match:substring match 8)))
171 (if ns
172 (string->number (string-drop ns 1))
173 0))
174 (ref 6) (ref 5) (ref 4)
175 (ref 3) (ref 2) (ref 1)
176 (+ (* 3600 (ref 9)) ;time zone
177 (if (< (ref 9) 0)
178 (- (ref 10))
179 (ref 10))))))
180 str)) ;oops!
181
182(define* (call url decode #:optional (method http-get)
183 #:key (false-if-404? #t))
184 "Invoke the endpoint at URL using METHOD. Decode the resulting JSON body
185using DECODE, a one-argument procedure that takes an input port. When
186FALSE-IF-404? is true, return #f upon 404 responses."
187 (let*-values (((response port)
188 (method url #:streaming? #t)))
189 ;; See <https://archive.softwareheritage.org/api/#rate-limiting>.
190 (match (assq-ref (response-headers response) 'x-ratelimit-remaining)
191 (#f #t)
192 ((? (compose zero? string->number))
193 (throw 'swh-error url response))
194 (_ #t))
195
196 (cond ((= 200 (response-code response))
197 (let ((result (decode port)))
198 (close-port port)
199 result))
200 ((and false-if-404?
201 (= 404 (response-code response)))
202 (close-port port)
203 #f)
204 (else
205 (close-port port)
206 (throw 'swh-error url response)))))
207
208(define-syntax define-query
209 (syntax-rules (path)
210 "Define a procedure that performs a Software Heritage query."
211 ((_ (name args ...) docstring (path components ...)
212 json->value)
213 (define (name args ...)
214 docstring
215 (call (swh-url components ...) json->value)))))
216
217;; <https://archive.softwareheritage.org/api/1/origin/git/url/https://github.com/guix-mirror/guix/>
218(define-json-mapping <origin> make-origin origin?
219 json->origin
220 (id origin-id)
221 (visits-url origin-visits-url "origin_visits_url")
222 (type origin-type)
223 (url origin-url))
224
225;; <https://archive.softwareheritage.org/api/1/origin/52181937/visits/>
226(define-json-mapping <visit> make-visit visit?
227 json->visit
228 (date visit-date "date" string->date*)
229 (origin visit-origin)
230 (url visit-url "origin_visit_url")
231 (snapshot-url visit-snapshot-url "snapshot_url")
232 (status visit-status)
233 (number visit-number "visit"))
234
235;; <https://archive.softwareheritage.org/api/1/snapshot/4334c3ed4bb208604ed780d8687fe523837f1bd1/>
236(define-json-mapping <snapshot> make-snapshot snapshot?
237 json->snapshot
238 (branches snapshot-branches "branches" json->branches))
239
240;; This is used for the "branches" field of snapshots.
241(define-record-type <branch>
242 (make-branch name target-type target-url)
243 branch?
244 (name branch-name)
245 (target-type branch-target-type) ;release | revision
246 (target-url branch-target-url))
247
248(define (json->branches branches)
249 (hash-map->list (lambda (key value)
250 (make-branch key
251 (string->symbol
252 (hash-ref value "target_type"))
253 (hash-ref value "target_url")))
254 branches))
255
256;; <https://archive.softwareheritage.org/api/1/release/1f44934fb6e2cefccbecd4fa347025349fa9ff76/>
257(define-json-mapping <release> make-release release?
258 json->release
259 (id release-id)
260 (name release-name)
261 (message release-message)
262 (target-type release-target-type "target_type" string->symbol)
263 (target-url release-target-url "target_url"))
264
265;; <https://archive.softwareheritage.org/api/1/revision/359fdda40f754bbf1b5dc261e7427b75463b59be/>
266(define-json-mapping <revision> make-revision revision?
267 json->revision
268 (id revision-id)
269 (date revision-date "date" string->date*)
270 (directory revision-directory)
271 (directory-url revision-directory-url "directory_url"))
272
273;; <https://archive.softwareheritage.org/api/1/content/>
274(define-json-mapping <content> make-content content?
275 json->content
276 (checksums content-checksums "checksums" json->checksums)
277 (data-url content-data-url "data_url")
278 (file-type-url content-file-type-url "filetype_url")
279 (language-url content-language-url "language_url")
280 (length content-length)
281 (license-url content-license-url "license_url"))
282
283(define (json->checksums checksums)
284 (hash-map->list (lambda (key value)
285 (cons key (base16-string->bytevector value)))
286 checksums))
287
288;; <https://archive.softwareheritage.org/api/1/directory/27c69c5d298a43096a53affbf881e7b13f17bdcd/>
289(define-json-mapping <directory-entry> make-directory-entry directory-entry?
290 json->directory-entry
291 (name directory-entry-name)
292 (type directory-entry-type "type"
293 (match-lambda
294 ("dir" 'directory)
295 (str (string->symbol str))))
296 (checksums directory-entry-checksums "checksums"
297 (match-lambda
298 (#f #f)
299 (lst (json->checksums lst))))
300 (id directory-entry-id "dir_id")
301 (length directory-entry-length)
302 (permissions directory-entry-permissions "perms")
303 (target-url directory-entry-target-url "target_url"))
304
305;; <https://archive.softwareheritage.org/api/1/origin/save/>
306(define-json-mapping <save-reply> make-save-reply save-reply?
307 json->save-reply
308 (origin-url save-reply-origin-url "origin_url")
309 (origin-type save-reply-origin-type "origin_type")
310 (request-date save-reply-request-date "save_request_date"
311 string->date*)
312 (request-status save-reply-request-status "save_request_status"
313 string->symbol)
314 (task-status save-reply-task-status "save_task_status"
315 (match-lambda
316 ("not created" 'not-created)
317 ((? string? str) (string->symbol str)))))
318
319;; <https://docs.softwareheritage.org/devel/swh-vault/api.html#vault-api-ref>
320(define-json-mapping <vault-reply> make-vault-reply vault-reply?
321 json->vault-reply
322 (id vault-reply-id)
323 (fetch-url vault-reply-fetch-url "fetch_url")
324 (object-id vault-reply-object-id "obj_id")
325 (object-type vault-reply-object-type "obj_type" string->symbol)
326 (progress-message vault-reply-progress-message "progress_message")
327 (status vault-reply-status "status" string->symbol))
328
329\f
330;;;
331;;; RPCs.
332;;;
333
334(define-query (lookup-origin url)
335 "Return an origin for URL."
336 (path "/api/1/origin/git/url" url)
337 json->origin)
338
339(define-query (lookup-content hash type)
340 "Return a content for HASH, of the given TYPE--e.g., \"sha256\"."
341 (path "/api/1/content"
342 (string-append type ":"
343 (bytevector->base16-string hash)))
344 json->content)
345
346(define-query (lookup-revision id)
347 "Return the revision with the given ID, typically a Git commit SHA1."
348 (path "/api/1/revision" id)
349 json->revision)
350
351(define-query (lookup-directory id)
352 "Return the directory with the given ID."
353 (path "/api/1/directory" id)
354 json->directory-entries)
355
356(define (json->directory-entries port)
357 (map json->directory-entry (json->scm port)))
358
359(define (origin-visits origin)
360 "Return the list of visits of ORIGIN, a record as returned by
361'lookup-origin'."
362 (call (swh-url (origin-visits-url origin))
363 (lambda (port)
364 (map json->visit (json->scm port)))))
365
366(define (visit-snapshot visit)
367 "Return the snapshot corresponding to VISIT."
368 (call (swh-url (visit-snapshot-url visit))
369 json->snapshot))
370
371(define (branch-target branch)
372 "Return the target of BRANCH, either a <revision> or a <release>."
373 (match (branch-target-type branch)
374 ('release
375 (call (swh-url (branch-target-url branch))
376 json->release))
377 ('revision
378 (call (swh-url (branch-target-url branch))
379 json->revision))))
380
381(define (lookup-origin-revision url tag)
382 "Return a <revision> corresponding to the given TAG for the repository
383coming from URL. Example:
384
385 (lookup-origin-release \"https://github.com/guix-mirror/guix/\" \"v0.8\")
386 => #<<revision> id: \"44941…\" …>
387
388The information is based on the latest visit of URL available. Return #f if
389URL could not be found."
390 (match (lookup-origin url)
391 (#f #f)
392 (origin
393 (match (origin-visits origin)
394 ((visit . _)
395 (let ((snapshot (visit-snapshot visit)))
396 (match (and=> (find (lambda (branch)
397 (string=? (string-append "refs/tags/" tag)
398 (branch-name branch)))
399 (snapshot-branches snapshot))
400 branch-target)
401 ((? release? release)
402 (release-target release))
403 ((? revision? revision)
404 revision)
405 (#f ;tag not found
406 #f))))
407 (()
408 #f)))))
409
410(define (release-target release)
411 "Return the revision that is the target of RELEASE."
412 (match (release-target-type release)
413 ('revision
414 (call (swh-url (release-target-url release))
415 json->revision))))
416
417(define (directory-entry-target entry)
418 "If ENTRY, a directory entry, has type 'directory, return its list of
419directory entries; if it has type 'file, return its <content> object."
420 (call (swh-url (directory-entry-target-url entry))
421 (match (directory-entry-type entry)
422 ('file json->content)
423 ('directory json->directory-entries))))
424
425(define* (save-origin url #:optional (type "git"))
426 "Request URL to be saved."
427 (call (swh-url "/api/1/origin/save" type "url" url) json->save-reply
428 http-post))
429
430(define-query (save-origin-status url type)
431 "Return the status of a /save request for URL and TYPE (e.g., \"git\")."
432 (path "/api/1/origin/save" type "url" url)
433 json->save-reply)
434
435(define-query (query-vault id kind)
436 "Ask the availability of object ID and KIND to the vault, where KIND is
437'directory or 'revision. Return #f if it could not be found, or a
438<vault-reply> on success."
439 ;; <https://docs.softwareheritage.org/devel/swh-vault/api.html#vault-api-ref>
440 ;; There's a single format supported for directories and revisions and for
441 ;; now, the "/format" bit of the URL *must* be omitted.
442 (path "/api/1/vault" (symbol->string kind) id)
443 json->vault-reply)
444
445(define (request-cooking id kind)
446 "Request the cooking of object ID and KIND (one of 'directory or 'revision)
447to the vault. Return a <vault-reply>."
448 (call (swh-url "/api/1/vault" (symbol->string kind) id)
449 json->vault-reply
450 http-post))
451
452(define* (vault-fetch id kind
453 #:key (log-port (current-error-port)))
454 "Return an input port from which a bundle of the object with the given ID
455and KIND (one of 'directory or 'revision) can be retrieved, or #f if the
456object could not be found.
457
458For a directory, the returned stream is a gzip-compressed tarball. For a
459revision, it is a gzip-compressed stream for 'git fast-import'."
460 (let loop ((reply (query-vault id kind)))
461 (match reply
462 (#f
463 (and=> (request-cooking id kind) loop))
464 (_
465 (match (vault-reply-status reply)
466 ('done
467 ;; Fetch the bundle.
468 (let-values (((response port)
469 (http-get (swh-url (vault-reply-fetch-url reply))
470 #:streaming? #t)))
471 (if (= (response-code response) 200)
472 port
473 (begin ;shouldn't happen
474 (close-port port)
475 #f))))
476 ('failed
477 ;; Upon failure, we're supposed to try again.
478 (format log-port "SWH vault: failure: ~a~%"
479 (vault-reply-progress-message reply))
480 (format log-port "SWH vault: retrying...~%")
481 (loop (request-cooking id kind)))
482 ((and (or 'new 'pending) status)
483 ;; Wait until the bundle shows up.
484 (let ((message (vault-reply-progress-message reply)))
485 (when (eq? 'new status)
486 (format log-port "SWH vault: \
487requested bundle cooking, waiting for completion...~%"))
488 (when (string? message)
489 (format log-port "SWH vault: ~a~%" message))
490
491 ;; Wait long enough so we don't exhaust our maximum number of
492 ;; requests per hour too fast (as of this writing, the limit is 60
493 ;; requests per hour per IP address.)
494 (sleep (if (eq? status 'new) 60 30))
495
496 (loop (query-vault id kind)))))))))
497
498\f
499;;;
500;;; High-level interface.
501;;;
502
503(define (commit-id? reference)
504 "Return true if REFERENCE is likely a commit ID, false otherwise---e.g., if
505it is a tag name."
506 (and (= (string-length reference) 40)
507 (string-every char-set:hex-digit reference)))
508
509(define (call-with-temporary-directory proc) ;FIXME: factorize
510 "Call PROC with a name of a temporary directory; close the directory and
511delete it when leaving the dynamic extent of this call."
512 (let* ((directory (or (getenv "TMPDIR") "/tmp"))
513 (template (string-append directory "/guix-directory.XXXXXX"))
514 (tmp-dir (mkdtemp! template)))
515 (dynamic-wind
516 (const #t)
517 (lambda ()
518 (proc tmp-dir))
519 (lambda ()
520 (false-if-exception (delete-file-recursively tmp-dir))))))
521
522(define (swh-download url reference output)
523 "Download from Software Heritage a checkout of the Git tag or commit
524REFERENCE originating from URL, and unpack it in OUTPUT. Return #t on success
525and #f on failure.
526
527This procedure uses the \"vault\", which contains \"cooked\" directories in
528the form of tarballs. If the requested directory is not cooked yet, it will
529wait until it becomes available, which could take several minutes."
530 (match (if (commit-id? reference)
531 (lookup-revision reference)
532 (lookup-origin-revision url reference))
533 ((? revision? revision)
534 (call-with-temporary-directory
535 (lambda (directory)
536 (let ((input (vault-fetch (revision-directory revision) 'directory))
537 (tar (open-pipe* OPEN_WRITE "tar" "-C" directory "-xzvf" "-")))
538 (dump-port input tar)
539 (close-port input)
540 (let ((status (close-pipe tar)))
541 (unless (zero? status)
542 (error "tar extraction failure" status)))
543
544 (match (scandir directory)
545 (("." ".." sub-directory)
546 (copy-recursively (string-append directory "/" sub-directory)
547 output
548 #:log (%make-void-port "w"))
549 #t))))))
550 (#f
551 #f)))