;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; ;;; REST API bindings for sr.ht ;;; ;;; Copyright (c) 2019-2020, Evan Hanson ;;; ;;; See LICENSE for details. ;;; (declare (module sourcehut) (export ;; public api access-token service-domain create retrieve update delete page ;; internal use keyword-arguments->alist signal-condition)) (import (chicken condition) (chicken format) (chicken io) (chicken keyword) (chicken port) (chicken process-context) (chicken type) (http-client) (medea) (intarweb) (uri-common)) ;;; ;;; Configurables. ;;; ;; ;; (: access-token (#!optional (or string false) -> (or string false))) (define access-token (make-parameter #f)) (: service-domain (#!optional string -> string)) (define service-domain (make-parameter "sr.ht")) ;;; ;;; Some helpers for the other libraries to use. ;;; (: signal-condition (#!rest (list-of any) -> any)) (define signal-condition (compose signal condition)) (: keyword-arguments->alist (list -> (list-of pair))) (define (keyword-arguments->alist list) (map (lambda (pair) (cons (keyword->symbol (car pair)) (cadr pair))) (chop list 2))) ;;; ;;; Keyword shenanigans. ;;; (define keyword->symbol (compose string->symbol keyword->string)) (define (set-keyword key args value #!optional (default (constantly #f))) (update-keyword key args (lambda (_) value) default)) (define (update-keyword key args update #!optional (default (constantly #f))) (let loop ((args args)) (cond ((null? args) (list key (update (default)))) ((eq? (car args) key) (cons (car args) (cons (update (cadr args)) (cddr args)))) (else (cons (car args) (cons (cadr args) (loop (cddr args)))))))) (define (delete-keyword key args) (let loop ((args args)) (cond ((null? args) args) ((eq? (car args) key) (cddr args)) (else (cons (car args) (cons (cadr args) (loop (cddr args)))))))) ;;; ;;; HTTP helpers. ;;; (define (json-response? response) (eq? (header-value 'content-type (response-headers response)) 'application/json)) (define (empty-response? response) (eq? (response-status response) 'no-content)) (define (not-found-condition? e) (eq? (response-status (client-error-response e)) 'not-found)) (define client-error-response (condition-property-accessor 'client-error 'response)) (define client-error-body (condition-property-accessor 'client-error 'body)) (define (signal-service-condition e) (signal (make-composite-condition e (condition (let ((body (client-error-body e))) (if (not body) '(sourcehut) `(sourcehut body ,(read-json body)))))))) ;;; ;;; Miniature CRUD framework. ;;; (define (query crud . args) (cons (update-keyword #:query (car crud) (lambda (query) (foldl (lambda (query kv) (alist-update (car kv) (cdr kv) query)) query (keyword-arguments->alist args))) (lambda () '())) (cdr crud))) (define (body crud . args) (let ((spec (car crud)) (body (optional args (cdr crud)))) (cons (if (eq? (get-keyword #:method spec) 'GET) spec (set-keyword #:body spec body)) (cdr crud)))) (define (submit-crud-request method crud) (apply submit-api-request method: method (car crud))) (define ((make-crud-dispatcher method) crud) (##sys#check-pair crud method) ; FIXME (call-with-values (lambda () (submit-crud-request method (body crud))) (lambda (result response) (if (or (null? result) (pair? result)) (values (cons (car crud) result) response) (values result response))))) (: create ((list-of pair) -> any (struct response))) (define create (make-crud-dispatcher 'POST)) (: retrieve ((list-of pair) -> any (struct response))) (define retrieve (make-crud-dispatcher 'GET)) (: update ((list-of pair) -> any (struct response))) (define update (make-crud-dispatcher 'PUT)) (: delete ((list-of pair) -> any (struct response))) (define delete (make-crud-dispatcher 'DELETE)) ;;; ;;; Service-specific combinators. ;;; (: page ((list-of pair) number -> (list-of pair))) (define (page crud start) (query crud start: start)) ;;; ;;; The actual request/response cycle. ;;; (define (authorization-header) `#(token ,(or (access-token) (get-environment-variable "SRHT_ACCESS_TOKEN") (error 'access-token "no access-token defined")))) (define (make-request-with-content-type #!rest args #!key method form) (let ((request (apply make-request method: method args)) (content-type (if form 'multipart/form-data 'application/json))) (update-request request headers: (replace-header-contents 'content-type `(#(,content-type ())) (request-headers request))))) (define (make-authorized-request service method path query form) (let ((host (format "~A.~A" service (service-domain)))) (make-request-with-content-type method: method form: form uri: (update-uri (uri-reference path) scheme: 'https host: host query: query) headers: (headers `((authorization ,(authorization-header))))))) (define (read-json-body port) (let ((body (read-json port))) (if (alist-ref 'errors body) (signal (condition (list 'sourcehut 'body body))) (values body)))) (define (submit-api-request #!key service method path form body query) (parameterize ((authorization-param-subunparsers (cons (cons 'token (lambda (token) token)) (authorization-param-subunparsers)))) (let ((request (make-authorized-request service method path query form))) (handle-exceptions e (condition-case (signal e) ; FIXME (e (http client-error) (let ((response (client-error-response e))) (if (not-found-condition? e) (values #f response) (signal-service-condition e)))) (e () (signal-service-condition e))) (receive (result uri response) ;; https://todo.sr.ht/~sircmpwn/sr.ht/184 (fluid-let ((symbol->http-name symbol->string)) (call-with-input-request* request (cond ((pair? form) form) ((pair? body) (call-with-output-string (lambda (port) (write-json body port)))) (else #f)) (lambda (port response) (if (json-response? response) (read-json-body port) (read-string #f port))))) (if (empty-response? response) (values #t response) (values result response)))))))