;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; ;;; REST API bindings for sr.ht ;;; ;;; Copyright (c) 2019, Evan Hanson ;;; ;;; See LICENSE for details. ;;; (declare (module topham) (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 (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 (make-service-condition e) (make-composite-condition e (condition (let ((body (read-json (client-error-body e)))) (if (not body) '(topham) `(topham body ,body)))))) ;;; ;;; Miniature CRUD framework. ;;; (define (crud spec body) (cons spec body)) (define (query crud . args) (cons (update-keyword #:query (car crud) (lambda (query) (append (keyword-arguments->alist args) query)) (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 (read-only crud) (cons (delete-keyword #:method (car crud)) (cdr crud))) (define read-only-crud (compose read-only crud)) (define (submit-crud-request crud) (apply submit-api-request (car crud))) (define ((make-crud-dispatcher method handler) crud) (##sys#check-pair crud method) ; FIXME (let ((spec (car crud))) (if (eq? (get-keyword #:method spec) method) (call-with-values (lambda () (submit-crud-request (body crud))) (lambda (result response) (if (or (null? result) (pair? result)) (values (handler spec result) response) (values result response)))) (signal-condition '(topham) '(request) `(exn message ,(format "invalid request (expected method: ~a)" method) arguments (,spec)))))) (: create ((list-of pair) -> any (struct response))) (define create (make-crud-dispatcher 'POST read-only-crud)) (: retrieve ((list-of pair) -> any (struct response))) (define retrieve (make-crud-dispatcher 'GET crud)) (: update ((list-of pair) -> any (struct response))) (define update (make-crud-dispatcher 'PUT read-only-crud)) (: delete ((list-of pair) -> any (struct response))) (define delete (make-crud-dispatcher 'DELETE read-only-crud)) ;;; ;;; 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-json-request #!rest args #!key method) (let ((request (apply make-request method: method args))) (if (eq? method 'GET) request (update-request request headers: (replace-header-contents 'content-type '(#(application/json ())) (request-headers request)))))) (define (make-authorized-json-request service method path query) (let ((host (format "~A.~A" service (service-domain)))) (make-json-request method: method uri: (update-uri (uri-reference path) scheme: 'https host: host query: query) headers: (headers `((authorization ,(authorization-header))))))) (define (submit-api-request #!key service method path body query) (parameterize ((authorization-param-subunparsers (cons (cons 'token (lambda (token) token)) (authorization-param-subunparsers)))) (let ((request (make-authorized-json-request service method path query))) (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 (make-service-condition e))))) (e () (signal (make-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 (and body (call-with-output-string (lambda (port) (write-json body (current-error-port)) (newline (current-error-port)) (write-json body port)))) (lambda (port response) (if (json-response? response) (read-json port) (read-string #f port))))) (values result response))))))