diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33f04a2be..fbc92f829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: gambit-version: ['v4.9.5', 'master'] shared-mode: ['', '--enable-shared'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install dependencies run: sudo apt-get -y install openssl libssl-dev sqlite3 libsqlite3-dev - name: Configure Gerbil diff --git a/.gitignore b/.gitignore index 0e6199515..fb75b241d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ gambit-source attic bin lib +/build *.o *.o? *.so diff --git a/src/std/build-spec.ss b/src/std/build-spec.ss index 36fdb490c..be6490136 100644 --- a/src/std/build-spec.ss +++ b/src/std/build-spec.ss @@ -12,6 +12,7 @@ "pregexp" "sort" "sugar" + "values" "assert" "make" "build-script" @@ -177,6 +178,7 @@ "text/json/env" "text/json/input" "text/json/output" + "text/json/util" "text/json/api" "text/json" ,@(if config-enable-libyaml @@ -205,6 +207,7 @@ "net/ssl" "net/uri" "net/request" + "net/json-rpc" ;; TODO to be ported ;;"net/websocket" ;; TODO to be removed @@ -305,6 +308,8 @@ "crypto/kdf" "crypto" ;; :std/misc + "misc/atom" + "misc/with-id" "misc/concurrent-plan" "misc/timeout" "misc/list-builder" diff --git a/src/std/error.ss b/src/std/error.ss index 237f1205f..615391c63 100644 --- a/src/std/error.ss +++ b/src/std/error.ss @@ -37,3 +37,5 @@ (def (raise-timeout where what . irritants) (raise (make-timeout-error what irritants where))) + +(defclass Exception ()) ;; for exceptions as classes rather than structs diff --git a/src/std/misc/atom.ss b/src/std/misc/atom.ss new file mode 100644 index 000000000..644eacb0a --- /dev/null +++ b/src/std/misc/atom.ss @@ -0,0 +1,118 @@ +;;; -*- Gerbil -*- +;;; © fare, vyzo +;;; Atoms in the style of clojure: shared, synchronous, independent state + +;; NB: Compared to the Clojure atom API, we: +;; - Do not support the meta field and the functions alter-meta! reset-meta! +;; - Replace swap-vals! and reset-vals! by swap-values! and reset-values! +;; returning values rather than vectors. +;; - Rename add-watch and remove-watch to add-watch! and remove-watch! +;; - Do not have generic functions that work on multiple kinds of "references" +;; but only one set of functions for atoms. +;; - Also export atom-increment! and atomic-counter for the common case. + +(import :gerbil/gambit/threads + :std/sugar) + +(export atom + (rename: Atom-value atom-deref) + (rename: swap! atom-swap!) + (rename: swap-values! atom-swap-values!) + (rename: reset! atom-reset!) + (rename: reset-values! atom-reset-values!) + (rename: compare-and-set! atom-compare-and-set!) + (rename: Atom-validator atom-validator) + (rename: Atom-validator-set! atom-validator-set!) + (rename: add-watch! atom-add-watch!) + (rename: remove-watch! atom-remove-watch!) + atom-increment! atomic-counter) + +(defstruct Atom + (mutex value validator watchers) + final: #t unchecked: #t) + +(def (atom (initial-value (void)) validator: (validator #f)) + (Atom (make-mutex 'atom) initial-value validator (hash))) + +(defalias &deref &Atom-value) + +;; Internal helper to ensure atomicity +(defrule (with-atom-lock a body ...) + (with-lock (Atom-mutex a) (lambda () body ...))) + +;; Internal helper to be called inside with-atom-lock +(def (update-atom! a new-val (values? #t)) + (with ((Atom _ old-val validator watchers) a) + (when validator + (unless (validator new-val) + (error "invalid atom value" new-val))) + (set! (&Atom-value a) new-val) + (hash-for-each (lambda (key watch) (watch key a old-val new-val)) watchers) + (if values? + (values old-val new-val) + new-val))) + +;; analog to Clojure swap-values!, but returning (values old-val new-val) rather than [old-val new-val] +(def* swap-values! + ((a f) + (with-atom-lock a (update-atom! a (f (&deref a))))) + ((a f val) + (with-atom-lock a (update-atom! a (f (&deref a) val)))) + ((a f val . rest) + (with-atom-lock a (update-atom! a (apply f (&deref a) val rest))))) + +(def* swap-value! + ((a f) + (with-atom-lock a (update-atom! a (f (&deref a)) #f))) + ((a f val) + (with-atom-lock a (update-atom! a (f (&deref a) val) #f))) + ((a f val . rest) + (with-atom-lock a (update-atom! a (apply f (&deref a) val rest) #f)))) + +;; same as Clojure swap! +(def* swap! + ((a f) + (swap-value! a f)) + ((a f val) + (swap-value! a f val)) + ((a f val . rest) + (apply swap-value! a f rest))) + +;; analog to Clojure reset-values!, but returning (values old-val new-val) rather than [old-val new-val] +(def (reset-values! a new-val) + (with-atom-lock a (update-atom! a new-val))) + +;; same as Clojure reset! +(def (reset! a new-val) + (with-atom-lock a (update-atom! a new-val #f))) + +;; same as Clojure compare-and-set! +(def (compare-and-set! a old-val new-val) + (with-atom-lock a + (and (eq? (&deref a) old-val) + (update-atom! a new-val #f) + #t))) + +;; same as Clojure add-watch +(def (add-watch! a key fn) + (with-atom-lock a + (hash-put! (&Atom-watchers a) key fn))) + +;; same as Clojure remove-watch +(def (remove-watch! a key) + (with-atom-lock a + (hash-remove! (&Atom-watchers a) key))) + +(def (atom-increment! atom (increment 1)) + (swap! atom + increment)) + +(def (atomic-counter (initial-value -1)) + (let (a (atom initial-value)) + (lambda () + (let (mx (&Atom-mutex a)) + (mutex-lock! mx) + (let (new (1+ (&Atom-value a))) + (set! (&Atom-value a) + new) + (mutex-unlock! mx) + new))))) diff --git a/src/std/misc/list-test.ss b/src/std/misc/list-test.ss index c2210eaf8..382a130d9 100644 --- a/src/std/misc/list-test.ss +++ b/src/std/misc/list-test.ss @@ -156,6 +156,9 @@ (check-equal? (group [1]) [[1]]) (check-equal? (group [1 []]) [[1] [[]]]) (check-equal? (group ["aa" "aa" "b"]) [["aa" "aa"] ["b"]])) + (test-case "grouping" + (check-equal? (grouping '("abc" "b" "c" "ef" "gh" "ijk") string-length) + '(("abc" "ijk") ("b" "c") ("ef" "gh")))) (test-case "test every-consecutive?" (check-equal? (every-consecutive? < [1 2 3 4 5]) #t) (check-equal? (every-consecutive? < [1 2 5 4 3]) #f) diff --git a/src/std/misc/list.ss b/src/std/misc/list.ss index 1209f09c9..6346f3b40 100644 --- a/src/std/misc/list.ss +++ b/src/std/misc/list.ss @@ -18,7 +18,8 @@ butlast split take-until take-until! drop-until - group + group group-by grouping + map/car every-consecutive? separate-keyword-arguments first-and-only @@ -34,7 +35,8 @@ (only-in ../srfi/1 drop drop-right drop-right! take take-right take! reverse! take-while take-while! drop-while - delete-duplicates delete-duplicates!) + delete-duplicates delete-duplicates! + split-at) ../sugar ../assert) @@ -284,7 +286,7 @@ ;; (drop-until number? ['a [] "hi" 1 'c]) => (1 c) (def (drop-until pred list) (drop-while (? (not pred)) list)) -;; group consecutive elements of the list lst into a list-of-lists. +;; group consecutive equal elements of the list lst into a list-of-lists. ;; ;; Example: ;; (group [1 2 2 3 1 1]) => ((1) (2 2) (3) (1 1)) @@ -302,6 +304,36 @@ ([a] [[a]]) (_ (helper)))) +;; group consecutive clusters of n elements of the list into a list-of-lists +;; The last element of the list returned may have fewer than n elements. +;; : Nat (List X) -> (List (List X)) +(def (group-by n list) + (cond + ((null? list) []) + ((length<=n? list n) [list]) + (else (let-values (((head tail) (split-at list n))) (cons head (group-by n tail)))))) + +;; Given a list l of X, a key function f from X to Y, and a presumably empty table of Y to a list of X +;; (by default an empty hash-table), add the elements in l to t, then return the list for each added +;; key y in t of the list of elements xs with that same key y (for the equality predicate of t). +;; Otherwise preserve the order of appearance of keys and elements for each key. +;; : (List X) (Fun X -> Y) ?(Table Y -> (List X)) -> (List (List X)) +(def (grouping l f (t (make-hash-table))) + (def ys (with-list-builder (c) + (for-each! l + (lambda (x) + (def y (f x)) + (def p (hash-get t y)) + (if p + (hash-put! t y (cons x p)) + (begin + (hash-put! t y (list x)) + (c y))))))) + (map (lambda (y) (reverse (hash-get t y))) ys)) + +;; : (A -> C) (Cons A B) -> (Cons C B) +(def (map/car f x) (match x ([a . b] [(f a) . b]))) + ;; Returns a boolean that is true if any two consecutive terms in the list satisfy the predicate. ;; In particular, if the predicate is a partial order predicate (respectively a strict partial ;; order predicate), then the list is totally ordered (respectively strictly totally ordered) diff --git a/src/std/misc/process.ss b/src/std/misc/process.ss index fb3ce27b1..ca3b78d3c 100644 --- a/src/std/misc/process.ss +++ b/src/std/misc/process.ss @@ -5,11 +5,14 @@ (export invoke run-process - run-process/batch) + run-process/batch + filter-with-process) (import :gerbil/gambit/ports - :std/misc/ports :std/sugar) + :gerbil/gambit/threads + :std/misc/ports + :std/sugar) ;; Error (def (check-process-success exit-status settings) @@ -82,16 +85,38 @@ (def (invoke program args stdout-redirection: (stdout-r #f) stderr-redirection: (stderr-r #f) - stdin-redirection: (stdin-r #f)) - (let* ((process (open-process [path: program arguments: args - stdout-redirection: stdout-r - stderr-redirection: stderr-r - stdin-redirection: stdin-r])) - (status (process-status process))) - (try - (unless (zero? status) - (error "Process invocation exited with non-zero status" status (cons program args))) - (when stdout-r - (read-line process #f)) - (finally - (close-port process))))) \ No newline at end of file + stdin-redirection: (stdin-r #f) + coprocess: (coprocess (if stdout-r read-all-as-string void)) + check-status: (check-status #t) + environment: (environment #f) + directory: (directory #f) + show-console: (show-console #f)) + (run-process (cons program args) + stdout-redirection: stdout-r + stderr-redirection: stderr-r + stdin-redirection: stdin-r + check-status: check-status + environment: environment + directory: directory + show-console: show-console)) + +;; write data into a filter process and read some data back. +;; process-options as per open-process, except you should only use +;; path: arguments: directory: environment: +(def (filter-with-process command writer reader) + (run-process + command + coprocess: + (lambda (process) + (spawn/name + ['writing-to command] + (lambda () + (try + (writer process) + (force-output process) + (finally + (close-output-port process))))) + (try + (reader process) + (finally + (close-port process)))))) diff --git a/src/std/misc/string-test.ss b/src/std/misc/string-test.ss index 7b4fec0cb..ee0eab12e 100644 --- a/src/std/misc/string-test.ss +++ b/src/std/misc/string-test.ss @@ -84,4 +84,14 @@ (check (str #(1 2)) => "(vector 1 2)") (check (str (values 1 2)) => "(values 1 2)") (check (str (make-point 1 2)) => "(point 1 2)")) + (test-case "test string-substitute-char" + (check-equal? (string-substitute-char "banana" #\o #\a) "bonono") + (check-equal? (string-substitute-char "banana" #\o #\a start: 3) "banono") + (check-equal? (string-substitute-char "banana" #\o #\a end: 5) "bonona") + (check-equal? (string-substitute-char "banana" #\o #\a start: 1 end: 5) "bonona") + (check-equal? (string-substitute-char "banana" #\o #\a count: 2) "bonona") + (check-equal? (string-substitute-char "banana" #\o #\a count: 2 from-end: #t) "banono") + (check-equal? (string-substitute-char "banana" #\o #\c test: char>?) "oonono") + (check-equal? (string-substitute-char "banana" #\o #\a test-not: equal?) "oaoaoa") + (check-equal? (string-substitute-char "banana" #\o #\A key: char-upcase) "bonono")) )) diff --git a/src/std/misc/string.ss b/src/std/misc/string.ss index 8b06cda4a..847b86842 100644 --- a/src/std/misc/string.ss +++ b/src/std/misc/string.ss @@ -9,6 +9,7 @@ string-split-eol string-trim-eol string-subst + string-substitute-char string-whitespace? random-string str str-format @@ -19,7 +20,8 @@ (only-in :gerbil/gambit/random random-integer) :std/srfi/13 :std/format - ) + :std/iter + :std/misc/number) ;; If the string starts with given prefix, return the end of the string after the prefix. ;; Otherwise, return the entire string. NB: Only remove the prefix once. @@ -254,3 +256,51 @@ ((? (and number? inexact?) v) "~f") ((? (or list? hash-table? vector? ##values? obj-pr?) v) "~r") (else "~a"))) + +;; Like CL SUBSTITUTE-IF but specialized for strings and chars. Mind the argument order. +(def (string-substitute-char-if + string newchar predicate + start: (start #f) + end: (end #f) + from-end: (from-end? #f) + count: (count #f) + in-place: (in-place? #f)) + (unless start (set! start 0)) + (unless end (set! end (string-length string))) + (def s (if in-place? string (string-copy string))) + (let/cc return + (cond + ((equal? count 0) (return)) + (count + (for (i (if from-end? (in-range (1- end) (1- start) -1) (in-range start end))) + (when (predicate (string-ref s i)) + (string-set! s i newchar) + (decrement! count) + (when (zero? count) (return))))) + (else + (for (i (in-range start end)) + (when (predicate (string-ref s i)) + (string-set! s i newchar)))))) + s) + +;; Like CL SUBSTITUTE but specialized for strings and chars. Mind the argument order. +(def (string-substitute-char + string newchar oldchar + test: (test #f) + test-not: (test-not #f) + key: (key #f) + start: (start #f) + end: (end #f) + from-end: (from-end? #f) + count: (count #f) + in-place: (in-place? #f)) + (let* ((key (or key identity)) + (predicate + (cond + (test (lambda (x) (test oldchar (key x)))) + (test-not (lambda (x) (not (test-not oldchar (key x))))) + (key (lambda (x) (eqv? oldchar (key x)))) + (else (cut eqv? oldchar <>))))) + (string-substitute-char-if + string newchar predicate + start: start end: end count: count from-end: from-end? in-place: in-place?))) diff --git a/src/std/misc/with-id-test.ss b/src/std/misc/with-id-test.ss new file mode 100644 index 000000000..bad9fb2fd --- /dev/null +++ b/src/std/misc/with-id-test.ss @@ -0,0 +1,39 @@ +(export with-id-test) + +(import + :gerbil/gambit/exceptions + :std/srfi/13 + :std/misc/number + :std/misc/string + :std/sugar + :std/stxutil + :std/test + ./with-id) + +(def with-id-test + (test-suite "test suite for std/misc/with-id" + (test-case "with-id, defining variables" + (def mem (make-vector 5 0)) + (defrule (defvar name n) + (with-id name ((@ #'name "@") (get #'name) (set #'name "-set!")) + (begin (def @ n) (def (get) (vector-ref mem @)) (def (set x) (vector-set! mem @ x))))) + (defvar A 0) + (defvar B 1) + (defvar C 2) + (defvar D 3) + (A-set! 42) (B-set! (+ (A) 27)) (increment! (C) 5) (D-set! (post-increment! (C) 18)) + (check-equal? mem #(42 69 23 5 0))) + (test-case "with-id, variable resolution in macro" + (check-exception + (eval '(begin + (defsyntax (m stx) + (def myvar "bar") + #'(with-id ctx ((foo my-var)) (def foo 2))) + (m))) + true) + (defsyntax (m stx) + (with-syntax ((ctx (stx-car stx)) + (myvar "bar")) + #'(with-id ctx ((foo #'myvar)) (def foo 3)))) + (m) + (check-equal? bar 3)))) diff --git a/src/std/misc/with-id.ss b/src/std/misc/with-id.ss new file mode 100644 index 000000000..b2e0d4f95 --- /dev/null +++ b/src/std/misc/with-id.ss @@ -0,0 +1,38 @@ +;;; -*- Gerbil -*- +;;; © fare +;;; Easier identifier introduction +(export #t) + +(import (for-syntax ./func + :std/stxutil) + :std/sugar) + +(defrules defsyntax/unhygienic () + ((_ (m-id stx) body ...) + (defsyntax m-id (compose syntax-local-introduce (lambda (stx) body ...) syntax-local-introduce))) + ((_ m-id f-expr) (identifier? #'m-id) + (defsyntax m-id (compose syntax-local-introduce f-expr syntax-local-introduce)))) + +;; Written with the precious help of Alex Knauth +(defsyntax (with-id stx) + (syntax-case stx () + ((wi (id-spec ...) body ...) + #'(wi wi (id-spec ...) body ...)) + ((wi ctx (id-spec ...) body body1 body+ ...) + (identifier? #'ctx) + #'(wi ctx (id-spec ...) (begin body body1 body+ ...))) + ((_ ctx (id-spec ...) template) + (identifier? #'ctx) + (with-syntax ((((id expr) ...) + (stx-map (lambda (spec) (syntax-case spec () + ((id) #'(id 'id)) + ((id ct-expr more ...) #'(id (list ct-expr more ...))) + (id (identifier? #'id) #'(id 'id)))) + #'(id-spec ...)))) + #'(begin + (defsyntax/unhygienic (m stx2) + (with-syntax ((id (identifierify (stx-car (stx-cdr stx2)) expr)) ...) + (... #'(... template)))) + (m ctx)))))) + +(defrule (with-id/expr stuff ...) (let () (with-id stuff ...))) diff --git a/src/std/net/httpd/api.ss b/src/std/net/httpd/api.ss index c33c222ba..9769293c8 100644 --- a/src/std/net/httpd/api.ss +++ b/src/std/net/httpd/api.ss @@ -37,4 +37,47 @@ set-httpd-max-token-length! set-httpd-max-request-body-length! set-httpd-input-buffer-size! - set-httpd-output-buffer-size!) + set-httpd-output-buffer-size! + http-response-write-condition + condition-handler + + Continue + Switching-Protocols + OK + Created + Accepted + Non-Authoritative-Information + No-Content + Reset-Content + Partial-Content + Multiple-Choices + Moved-Permanently + Found + See-Other + Not-Modified + Use-Proxy + Temporary-Redirect + Bad-Request + Unauthorized + Payment-Required + Forbidden + Not-Found + Method-Not-Allowed + Not-Acceptable + Proxy-Authentication-Required + Request-Timeout + Conflict + Gone + Length-Required + Precondition-Failed + Request-Entity-Too-Large + Request-URI-Too-Long + Unsupported-Media-Type + Requested-Range-Not-Satisfiable + Expectation-Failed + Internal-Server-Error + Not-Implemented + Bad-Gateway + Service-Unavailable + Gateway-Timeout + HTTP-Version-Not-Supported) diff --git a/src/std/net/httpd/handler.ss b/src/std/net/httpd/handler.ss index d9284bda5..04dc24712 100644 --- a/src/std/net/httpd/handler.ss +++ b/src/std/net/httpd/handler.ss @@ -11,7 +11,10 @@ :std/foreign :std/text/utf8 :std/pregexp - ./base) + :std/misc/with-id + ./base + (for-syntax :std/stxutil + :std/misc/string)) (export http-request-handler http-request? http-request-method http-request-url http-request-path http-request-params @@ -29,7 +32,9 @@ set-httpd-max-token-length! set-httpd-max-request-body-length! set-httpd-input-buffer-size! - set-httpd-output-buffer-size!) + set-httpd-output-buffer-size! + http-response-write-condition + condition-handler) (declare (not safe)) @@ -286,12 +291,11 @@ END-C (def input-buffer-size 8192) (def output-buffer-size 32768) -(defrules defsetter () - ((_ (setf id) pred) - (def (setf val) - (if (? pred val) - (set! id val) - (error "Cannot set httpd parameter; Bad argument" val))))) +(defrule (defsetter (setf id) pred) + (def (setf val) + (if (? pred val) + (set! id val) + (error "Cannot set httpd parameter; Bad argument" val)))) (defsetter (set-httpd-request-timeout! request-timeout) (or not real? time?)) @@ -598,47 +602,70 @@ END-C ("TRACE" 'TRACE) ("OPTIONS" 'OPTIONS))) -(def +http-response-codes+ - (hash-eq (100 "Continue") - (101 "Switching Protocols") - (200 "OK") - (201 "Created") - (202 "Accepted") - (203 "Non-Authoritative Information") - (204 "No Content") - (205 "Reset Content") - (206 "Partial Content") - (300 "Multiple Choices") - (301 "Moved Permanently") - (302 "Found") - (303 "See Other") - (304 "Not Modified") - (305 "Use Proxy") - (307 "Temporary Redirect") - (400 "Bad Request") - (401 "Unauthorized") - (402 "Payment Required") - (403 "Forbidden") - (404 "Not Found") - (405 "Method Not Allowed") - (406 "Not Acceptable") - (407 "Proxy Authentication Required") - (408 "Request Timeout") - (409 "Conflict") - (410 "Gone") - (411 "Length Required") - (412 "Precondition Failed") - (413 "Request Entity Too Large") - (414 "Request-URI Too Long") - (415 "Unsupported Media Type") - (416 "Requested Range Not Satisfiable") - (417 "Expectation Failed") - (500 "Internal Server Error") - (501 "Not Implemented") - (502 "Bad Gateway") - (503 "Service Unavailable") - (504 "Gateway Timeout") - (505 "HTTP Version Not Supported"))) +(def +http-response-codes+ (make-hash-table-eq)) +(defstruct http-condition (code message)) +(defrule (def-http-condition ctx code message) + (with-id ctx ((condition + (string-substitute-char (stringify #'message) #\- #\space))) + (def condition (make-http-condition code message)) + (export condition) + (hash-put! +http-response-codes+ code message))) +(defrules def-http-conditions () + ((ctx (number message) ...) (begin (def-http-condition ctx number message) ...))) +(def-http-conditions + (100 "Continue") + (101 "Switching Protocols") + (200 "OK") + (201 "Created") + (202 "Accepted") + (203 "Non-Authoritative Information") + (204 "No Content") + (205 "Reset Content") + (206 "Partial Content") + (300 "Multiple Choices") + (301 "Moved Permanently") + (302 "Found") + (303 "See Other") + (304 "Not Modified") + (305 "Use Proxy") + (307 "Temporary Redirect") + (400 "Bad Request") + (401 "Unauthorized") + (402 "Payment Required") + (403 "Forbidden") + (404 "Not Found") + (405 "Method Not Allowed") + (406 "Not Acceptable") + (407 "Proxy Authentication Required") + (408 "Request Timeout") + (409 "Conflict") + (410 "Gone") + (411 "Length Required") + (412 "Precondition Failed") + (413 "Request Entity Too Large") + (414 "Request-URI Too Long") + (415 "Unsupported Media Type") + (416 "Requested Range Not Satisfiable") + (417 "Expectation Failed") + (500 "Internal Server Error") + (501 "Not Implemented") + (502 "Bad Gateway") + (503 "Service Unavailable") + (504 "Gateway Timeout") + (505 "HTTP Version Not Supported")) + +(def (http-response-write-condition + res (condition #f) code: (code #f) content-type: (content-type #f) message: (message #f)) + (http-response-write res (or code (http-condition-code condition)) + `(("Content-Type" . ,(or content-type "text/plain"))) + (or message (http-condition-message condition)))) + +(def (condition-handler handler) + (lambda (req res) + (try (handler req res) + (catch (e) + (http-response-write-condition + res (if (http-condition? e) e Internal-Server-Error)))))) ;;; buffer management (extern namespace: #f diff --git a/src/std/net/json-rpc-test.ss b/src/std/net/json-rpc-test.ss new file mode 100644 index 000000000..677117296 --- /dev/null +++ b/src/std/net/json-rpc-test.ss @@ -0,0 +1,47 @@ +;;; -*- Gerbil -*- +;;; std/net/json-rpc unit-test + +(import :std/iter + :std/net/httpd + :std/net/request + :std/net/json-rpc + :std/sugar + :std/test) +(export json-rpc-test) + +(def server-address + "127.0.0.1:19998") + +(def server-url + (string-append "http://" server-address)) + +(def (json-rpc-test-processor method params) + (case method + (("ping") ["pong" params]) + (("add") (apply + params)) + (else (raise (method-not-found method))))) + +(def json-rpc-test + (test-suite "json-rpc test" + (def httpd + (start-http-server! server-address mux: (make-recursive-http-mux))) + (http-register-handler httpd "/json-rpc-test" (json-rpc-handler json-rpc-test-processor)) + (def url (string-append server-url "/json-rpc-test")) + (def (query . args) + (apply json-rpc url args)) + (def (this-json-rpc-error? number) + (lambda (e) (and (json-rpc-error? e) (equal? (json-rpc-error-code e) number)))) + (defrule (check-e expr code) (check-exception expr (this-json-rpc-error? code))) + (def (test-basic-handlers http-method) + (def (q . a) (apply query http-method: http-method a)) + (check (q "ping" '(42)) => '("pong" (42))) + (check (q "add" '(1 2 3 4)) => 10) + (check-e (q "ping" 42) -32602) ;; invalid-params + (check-e (q "meaning-of-life") -32601)) ;; method-not-found + (test-case "basic handlers with POST" + (test-basic-handlers 'POST) + (check-exception (query 42 http-method: 'POST) (this-json-rpc-error? -32600))) ;; invalid-request + (test-case "basic handlers with GET" + (test-basic-handlers 'GET) + (check-exception (query 42 http-method: 'GET) true)) ;; can't uri-encode number 42 + (stop-http-server! httpd))) diff --git a/src/std/net/json-rpc.ss b/src/std/net/json-rpc.ss new file mode 100644 index 000000000..52df117af --- /dev/null +++ b/src/std/net/json-rpc.ss @@ -0,0 +1,308 @@ +;;; -*- Gerbil -*- +;;; © fare +;;; JSON RPC interfaces + +;; Support for JSON RPC 2.0 -- https://www.jsonrpc.org/specification +;; JSON RPC over HTTP (historical): https://www.jsonrpc.org/historical/json-rpc-over-http.html +;; See also https://www.simple-is-better.org/json-rpc/transport_http.html#get-request +;; In practice, we try to support existing clients and servers, +;; that don't follow various subsets of the specification +;; (especially the HTTP spec, but many clients and servers only do 1.0, 1.1 or 1.2) +;; so we are not too strict in our checking. + +(export + ;; Main user-visible functions + json-rpc json-rpc-handler serve-json-rpc + + ;; Error handling + json-rpc-error json-rpc-error? json-rpc-error-code json-rpc-error-message json-rpc-error-data + json-rpc-version + parser-error invalid-request method-not-found invalid-params internal-error + application-error system-error tranport-error + malformed-request malformed-response) + +(import + (only-in :std/error Exception ) + (only-in :std/misc/atom atomic-counter) + (only-in :std/net/httpd http-response-write http-response-write-condition + http-request-body http-request-params http-request-method + Bad-Request Internal-Server-Error) + (only-in :std/net/request http-post http-get request-response-bytes) + (only-in :std/net/ssl default-client-ssl-context) + (only-in :std/net/uri form-url-decode uri-decode) + (only-in :std/sugar try catch hash) + (only-in :std/text/base64 u8vector->base64-string base64-string->u8vector) + (only-in :std/text/json trivial-json-object->class JSON json-symbolic-keys + bytes->json-object json-object->bytes json-object->string)) + +(defstruct (json-rpc-error ) () +;; (code ;; SInt16 +;; message ;; String +;; data) ;; (Maybe Bytes) + transparent: #t constructor: :init!) +(defmethod {:init! json-rpc-error} + (lambda (self what: (what "JSON RPC error") where: (where 'json-rpc) + code: code ;; SInt16 + message: message ;; String + data: (data (void))) ;; (Maybe Bytes) + (def irritants [code message data]) + (struct-instance-init! self what irritants where))) +(def (json-rpc-error-code e) + (car (error-irritants e))) +(def (json-rpc-error-message e) + (cadr (error-irritants e))) +(def (json-rpc-error-data e) + (caddr (error-irritants e))) +(defmethod {:json json-rpc-error} + (lambda (self) + (with ([code message data] (error-irritants self)) + (hash ("code" code) ("message" message) ("data" data))))) +(def (json->json-rpc-error json) + (trivial-json-object->class json-rpc-error::t json)) + +(def json-rpc-version "2.0") + +(defclass (json-rpc-request JSON) + (jsonrpc ;; String, must be the same as json-rpc-version ("2.0"), can undefined for version 1.0 + method ;; String + params ;; Json, array (arguments by position) or object (arguments by name) + id) ;; Json, MUST be an number, a string, or JSON null aka Scheme (void). SHOULD be an integer if a number. (void) if no response is required. + transparent: #t) + +(defclass (json-rpc-response JSON) + ;; Note: a 2.0 server MUST include only one of result or error. + ;; But a 1.0 or 1.1 server may leave the other null. + (jsonrpc ;; String, must be the same as json-rpc-version ("2.0") + result ;; Json, JSON null (Scheme void) if there is an error + error ;; Json, JSON null (Scheme void) if there is no error + id) ;; Json, MUST be the same as provided in the request. + transparent: #t + constructor: :init!) + +(defmethod {:init! json-rpc-response} + (lambda (self jsonrpc: (jsonrpc (void)) + result: (result (void)) + error: (error (void)) + id: (id (void))) + (class-instance-init! self jsonrpc: jsonrpc result: result error: error id: id))) + +;; Global counter to correlate responses and answers in logs. +(def id-counter (atomic-counter)) + +;; These functions construct error results to be returned by the json-rpc server to the client. +;; Beware: DO NOT LEAK internal information in such externally returned error messages. +;; Publish only what you must for the user's sake, +;; make sure any secrets are wrapped in structures, accessors and printers that won't leak them. +;; Log what you need, generate a reference as appropriate and include it in the message. +(def (parser-error (e (void))) + (json-rpc-error code: -32700 data: e + message: "An error occurred on the server while parsing the JSON text.")) +(def (invalid-request (e (void))) + (json-rpc-error code: -32600 message: "The JSON sent is not a valid Request object." data: e)) +(def (method-not-found (e (void))) + (json-rpc-error code: -32601 message: "The method does not exist / is not available." data: e)) +(def (invalid-params (e (void))) + (json-rpc-error code: -32602 message: "Invalid method parameter(s)." data: e)) +(def (internal-error (e (void))) + (json-rpc-error code: -32603 message: "Internal JSON-RPC error." data: e)) +(def (application-error m (e (void))) + (json-rpc-error code: -32500 message: m data: e)) +(def (system-error m (e (void))) + (json-rpc-error code: -32400 message: m data: e)) +(def (tranport-error m (e (void))) + (json-rpc-error code: -32300 message: m data: e)) + +(defclass (malformed-request JSON Exception) (method params e) transparent: #t) +(defclass (malformed-response JSON Exception) (request-id response e) transparent: #t) + +(def (bytes->json b) ;; Don't intern JSON keys, using strings + (parameterize ((json-symbolic-keys #f)) (bytes->json-object b))) + +;;; Client code +;; TODO: implement timeouts, with semi-asynchronous shutdown of the http-post thread itself. +(def (json-rpc server-url method (params (void)) + auth: (auth #f) + headers: (headers #f) + cookies: (cookies #f) + ssl-context: (ssl-context (default-client-ssl-context)) + result-decoder: (result-decoder identity) + param-encoder: (param-encoder identity) + log: (log #f) + http-method: (http-method 'POST)) + (def id (id-counter)) + (when log + (log [json-rpc: server-url method: method params: params id: id])) + (def response-bytes + (request-response-bytes + (case http-method + ((POST) ;; POST is the recommended http-method, and the only one supported by many servers. + (let (data + (try (json-object->bytes + (json-rpc-request jsonrpc: json-rpc-version + method: method params: params id: id)) + (catch (e) (raise (malformed-request method: method params: params + e: (error-message e)))))) + (http-post server-url + auth: auth + headers: `(("Content-Type" . "application/json-rpc") + ;; The JSON RPC over HTTP standard says we MUST send + ;; some variant of the Accept header below, but actually + ;; no client bothers sending it, no server bothers checking it, + ;; and it can only make things slower and trigger unwanted + ;; edge cases, so we don't bother sending it either. + ;; ("Accept" . "application/json-rpc, application/json, application/jsonrequest") + ,@headers) + ssl-context: ssl-context + cookies: cookies + data: data))) + ;; NB: the GET http-method is strongly disrecommended: + ;; It is only supported by few servers, + ;; only appropriate for calls to safe and idempotent methods + ;; that may or may not be cached on the way to the actual server, + ;; when the encoded parameters lead to a URI length too long (> 255 bytes ?) + ;; for the caches and proxies sitting between client and server. + ((GET) + (set! id (number->string id)) ;; GET method wants string id. + (let* ((base64-params + (try (u8vector->base64-string (json-object->bytes params)) + (catch (e) (raise (malformed-request + method: method params: params + e: (error-message e)))))) + (uri-params + `(("jsonrpc" .,json-rpc-version) + ("method" .,method) + ("params" .,base64-params) + ("id" .,id)))) + (http-get server-url + auth: auth + headers: `(("Content-Type" . "application/json-rpc") + ;; NB: we don't bother with an Accept header here, either. + ,@headers) + params: uri-params + ssl-context: ssl-context + cookies: cookies))) + (else (raise (error "Invalid http method" http-method)))))) + (def response-json + (try + (bytes->json response-bytes) ;; todo: move to decode-json-rpc-response ? + (catch (e) (raise (malformed-response request-id: id response: response-bytes e: e))))) + (when log + (log [from: server-url response: (bytes->string response-bytes)])) + (decode-json-rpc-response + result-decoder + (and (hash-table? response-json) (hash-get response-json "id")) + response-json)) + +(def (decode-json-rpc-response decoder request-id response-json) + (def (mal! e) + (raise (malformed-response request-id: request-id response: response-json e: (error-message e)))) + (def response (with-catch mal! (cut trivial-json-object->class json-rpc-response::t response-json))) + (def jsonrpc (@ response jsonrpc)) + (def result (@ response result)) + (def error (@ response error)) + (def id (@ response id)) + (unless (or (void? jsonrpc) ;; a 1.0 server might fail to include this field + (equal? jsonrpc json-rpc-version)) ;; but a recent server must reply with same version + (mal! "bad json_rpc_version")) + (unless (or (void? result) (void? error)) + (mal! "result error conflict")) + (unless (equal? id request-id) + (mal! "bad id")) + (if (void? error) + (with-catch mal! (cut decoder result)) + (raise (with-catch mal! (cut json->json-rpc-error error))))) + +;;; Server code + +;; http handler for json-rpc +;; NB: This will catch any exception raised and convert it into an error notified to the client. +;; TODO: have an optional parameter to specify a logging facility for those errors we find. +(def (json-rpc-handler processor log: (log #f)) + (lambda (req res) + ;; NB: the JSON RPC over HTTP says that the client MUST specify + ;; application/json-rpc (preferrably) or else application/json or application/jsonrequest + ;; in a Content-Type header, and MUST specify and Accept header with one (or many) of them + ;; and that a Content-Length header MUST be present... but frankly, no one bothers, + ;; and enforcing any of it would make the server needlessly incompatible with clients, + ;; so we don't bother either. + (def http-method (http-request-method req)) + (case http-method + ((POST) (json-rpc-handler/POST req res processor log)) ;; preferred method + ((GET) (json-rpc-handler/GET req res processor log)) ;; mostly for testing + (else + (when log (log [json-rpc-handler: 'BAD-HTTP-METHOD http-method])) + (http-response-write-condition res Bad-Request))))) + +(def (json-rpc-handler/POST req res processor log) + (let/cc return + (def request-json + (try + (bytes->json (http-request-body req)) + (catch (_) + (json-rpc-handler/response + res log 'BAD-POST + (hash ("jsonrpc" json-rpc-version) ("error" (parser-error)))) + (return)))) + (json-rpc-handler/JSON res processor log request-json))) + +(def (json-rpc-handler/GET req res processor log) + (let/cc return + (def request-json + (try + (def url-params (form-url-decode (http-request-params req))) + (def method (assget "method" url-params (void))) + (unless method (raise 'parser-error)) + (def params (bytes->json + (base64-string->u8vector + (uri-decode (assget "params" url-params))))) + (def json (hash ("method" method) ("params" params))) + (alet (jsonrpc (assget "jsonrpc" url-params)) + (hash-put! json "jsonrpc" jsonrpc)) + (alet (id (assget "id" url-params)) + (hash-put! json "id" id)) + json + (catch (_) + (json-rpc-handler/response + res log 'BAD-GET (hash ("jsonrpc" json-rpc-version) ("error" (parser-error)))) + (return)))) + (json-rpc-handler/JSON res processor log request-json))) + +(def (json-rpc-handler/JSON res processor log request-json) + (json-rpc-handler/response res log request-json (serve-json-rpc processor request-json))) + +;; The processor either returns a JSON object, or raise a json-rpc-error +;; Any other error raised will cause an internal error. +;; This function can conceivably be called over a transport other than HTTP. +(def (serve-json-rpc processor request-json) + (let/cc return + (def jsonrpc (hash-ref request-json "jsonrpc" (void))) + (def id (hash-ref request-json "id" (void))) + (def (return-error e) (return (hash ("jsonrpc" jsonrpc) ("error" e) ("id" id)))) + (def (invalid-req) (return-error (invalid-request))) + (for-each (lambda (k) (unless (member k '("jsonrpc" "method" "params" "id")) (invalid-req))) + (hash-keys request-json)) + (unless (member jsonrpc '(#!void "1.0" "2.0")) (set! jsonrpc json-rpc-version) (invalid-req)) + (def method (hash-ref request-json "method" (void))) + (unless (string? method) (invalid-req)) + (def params (hash-ref request-json "params" (void))) + (unless (or (list? params) (hash-table? params) (void? params)) (return-error (invalid-params))) + (def notification? (not (hash-key? request-json "id"))) + (unless (or (string? id) (number? id) (void? id)) (invalid-req)) + (try + ;; TODO: either log here, or define parameters that the processor can use to log context. + ;; Use hash so only one of result: or error: shall be printed + (def result (processor method params)) + (if notification? (void) (hash ("jsonrpc" jsonrpc) ("id" id) ("result" result))) + (catch (e) (return-error (if (json-rpc-error? e) e (internal-error))))))) + +(def (json-rpc-handler/response res log request-json response-json) + (let/cc return + (def response-text + (try + (json-object->string response-json) + (catch (_) + (when log (log [json-rpc-handler: request-json 'BAD-JSON-RESPONSE])) + (http-response-write-condition res Internal-Server-Error) + (return)))) + (when log (log [json-rpc-handler: request-json response-json])) + (http-response-write res 200 `(("Content-Type" . "text/json-rpc")) response-text))) diff --git a/src/std/net/request.ss b/src/std/net/request.ss index 378f8ddc0..64fdcb8cf 100644 --- a/src/std/net/request.ss +++ b/src/std/net/request.ss @@ -12,6 +12,8 @@ request-json request-cookies request-close + request-response-bytes + http-get-content (rename: request-sock request-socket) (rename: request-reader request-socket-reader) (rename: request-writer request-socket-writer)) @@ -504,3 +506,14 @@ (else (lp rest)))) (else (void)))))) + +(def (request-response-bytes req) + (try + (if (eq? (request-status req) 200) + (request-content req) + (error "HTTP request failed" (request-status req) (request-status-text req))) + (finally + (request-close req)))) + +(def (http-get-content url) + (and url (request-response-bytes (http-get url)))) diff --git a/src/std/net/uri.ss b/src/std/net/uri.ss index 9e1cab816..09b8c1449 100644 --- a/src/std/net/uri.ss +++ b/src/std/net/uri.ss @@ -7,7 +7,7 @@ with-output-to-u8vector write-u8) :std/text/utf8) -(export uri-encode uri-decode form-url-encode form-url-decode +(export uri-encode uri-decode form-url-encode form-url-decode query-string make-uri-encoding-table uri-unreserved-chars uri-gendelim-chars uri-subdelim-chars) @@ -48,7 +48,7 @@ (write-uri-encoded str vt)))) ;; form-url-encode: [[string . string/#f] ...] => string -;; if +space is t, #\space is encoded as #\+ (otherwise %20) +;; if +space? is #t, #\space is encoded as #\+ (otherwise %20) (def (form-url-encode fields (+space? #t)) (def encoding (if +space? uri-space-encoding uri-encoding)) @@ -70,6 +70,29 @@ rest)))) ([] ""))) +;; Create a query string... +;; BEWARE!!! This does NO VALIDATION of the command and option syntax. +;; The command should already be a valid URL path prefix. +;; The options fields ought to be explicitly encoded with uri-encode if needed. +(def (query-string path . options) + (call-with-output-string + '() + (lambda (o) + (display path o) + (let loop ((options options) + (separator #\?)) + (match options + ([] (void)) + ([key value . more] + (if value + (begin + (display separator o) + (display key o) + (display #\= o) + (display value o) + (loop more #\&)) + (loop more separator)))))))) + (def (write-uri-encoded str encoding) (def (write-hex n) (write-char (##string-ref "0123456789ABCDEF" n))) diff --git a/src/std/stxutil.ss b/src/std/stxutil.ss index bb414ac2e..31e5b7b54 100644 --- a/src/std/stxutil.ss +++ b/src/std/stxutil.ss @@ -2,8 +2,10 @@ ;;; © vyzo ;;; syntax utilities; import for-syntax (import - :std/format) -(export #t) + :gerbil/gambit/bytes + :std/format + :std/text/hex) +(export #t (for-syntax #t)) ;; format an identifier; see also stx-identifier ;; ctx := template identifier @@ -12,3 +14,36 @@ (def (format-id ctx fmt . args) (datum->syntax ctx (string->symbol (apply format fmt (map stx-e args))) (stx-source ctx))) + +;; Use maybe-intern-symbol instead of string->symbol to avoid DoS attacks +;; that cause you to intern too many symbols and run out of memory. +;; : (Or Symbol String) <- String +(def (maybe-intern-symbol string) + (or (##find-interned-symbol string) string)) + +;; Use maybe-intern-symbol instead of string->keyword to avoid DoS attacks +;; that cause you to intern too many keywords and run out of memory. +;; : (Or Keyword String) <- String +(def (maybe-intern-keyword string) + (or (##find-interned-keyword string) string)) + +(def (displayify x port) + (cond + ((member x '(#f #t () #!void #!eof)) (void)) + ((or (string? x) (symbol? x) (number? x)) (display x port)) + ((keyword? x) (display (keyword->string x) port)) + ((bytes? x) (display (bytes->string x) port)) + ((pair? x) (displayify (car x) port) (displayify (cdr x) port)) + ((vector? x) (displayify (vector->list x) port)) + ((AST? x) (displayify (stx-e x) port)) + (else (void)))) +(def (stringify . x) (call-with-output-string (lambda (port) (displayify x port)))) +(def symbolify (case-lambda ((x) (if (symbol? x) x (string->symbol (stringify x)))) + (x (string->symbol (stringify x))))) +(def keywordify (case-lambda ((x) (if (keyword? x) x (string->keyword (stringify x)))) + (x (string->keyword (stringify x))))) +(def maybe-symbolify (case-lambda ((x) (if (symbol? x) x (maybe-intern-symbol (stringify x)))) + (x (maybe-intern-symbol (stringify x))))) +(def maybe-keywordify (case-lambda ((x) (if (keyword? x) x (maybe-intern-keyword (stringify x)))) + (x (maybe-intern-keyword (stringify x))))) +(def (identifierify stx . x) (datum->syntax stx (apply symbolify x))) diff --git a/src/std/text/json/api.ss b/src/std/text/json/api.ss index 602f6937b..a2b9b4c79 100644 --- a/src/std/text/json/api.ss +++ b/src/std/text/json/api.ss @@ -1,46 +1,11 @@ ;;; -*- Gerbil -*- ;;; © vyzo ;;; json api -(import :gerbil/gambit/ports - :std/io - ./env - ./input - ./output) +(import ./env ./input ./output ./util) (export read-json write-json string->json-object json-object->string - json-symbolic-keys json-list-wrapper json-sort-keys) - -(def (read-json (input (current-input-port))) - (cond - ((input-port? input) - (read-json-object/port input (make-env))) - ((is-BufferedStringReader? input) - (read-json-object/reader (BufferedStringReader input) (make-env))) - ((is-BufferedReader? input) - (read-json-object/buffer (BufferedReader input) (make-env))) - (else - (error "Bad input source; expected input port, BufferedStringReader or BufferedReader instance" input)))) - -(def (string->json-object str) - (read-json-object/reader (open-buffered-string-reader str) (make-env))) - -(def (write-json obj (output (current-output-port))) - (cond - ((output-port? output) - (write-json-object/port obj output (make-env)) - (force-output output)) - ((is-BufferedStringWriter? output) - (write-json-object/writer obj (BufferedStringWriter output) (make-env))) - ((is-BufferedWriter? output) - (write-json-object/buffer obj (BufferedWriter output) (make-env))) - ((or (is-StringWriter? output) (is-Writer? output)) - (let (output (open-buffered-string-writer output)) - (write-json-object/writer obj output (make-env)) - (&BufferedStringWriter-flush output))) - (else - (error "Bad output sink; expected output port, Writer, StringWriter or BufferedStringWriter" output)))) - -(def (json-object->string obj) - (let (buffer (open-buffered-string-writer #f)) - (write-json-object/writer obj buffer (make-env)) - (get-buffer-output-string buffer))) + bytes->json-object json-object->bytes + json-symbolic-keys json-list-wrapper json-sort-keys + trivial-class->json-object trivial-json-object->class + trivial-struct->json-object trivial-json-object->struct + JSON pretty-json) diff --git a/src/std/text/json/input.ss b/src/std/text/json/input.ss index 3880520c1..785722411 100644 --- a/src/std/text/json/input.ss +++ b/src/std/text/json/input.ss @@ -30,18 +30,18 @@ (skip-whitespace input) (let (char (peek-char input)) (if (eof-object? char) - #!eof) - (case char - ((#\{) (read-json-hash input env)) - ((#\[) (read-json-list input env)) - ((#\") (read-json-string input env)) - ((#\t) (read-json-true input env)) - ((#\f) (read-json-false input env)) - ((#\n) (read-json-null input env)) - ((#\- #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7 #\8 #\9) - (read-json-number input env)) - (else - (raise-invalid-token input char))))) + #!eof + (case char + ((#\{) (read-json-hash input env)) + ((#\[) (read-json-list input env)) + ((#\") (read-json-string input env)) + ((#\t) (read-json-true input env)) + ((#\f) (read-json-false input env)) + ((#\n) (read-json-null input env)) + ((#\- #\0 #\1 #\2 #\3 #\4 #\5 #\6 #\7 #\8 #\9) + (read-json-number input env)) + (else + (raise-invalid-token input char)))))) (def (read-json-hash input env) (read-char input) @@ -228,4 +228,4 @@ (defjson-reader port peek-char read-char) (defjson-reader reader &BufferedStringReader-peek-char-inline &BufferedStringReader-read-char-inline) -(defjson-reader buffer &BufferedReader-peek-char-inline &BufferedReader-read-char-inline) \ No newline at end of file +(defjson-reader buffer &BufferedReader-peek-char-inline &BufferedReader-read-char-inline) diff --git a/src/std/text/json/output.ss b/src/std/text/json/output.ss index b27196738..766bf1c87 100644 --- a/src/std/text/json/output.ss +++ b/src/std/text/json/output.ss @@ -202,4 +202,4 @@ (defjson-writer port write-char write-string) (defjson-writer writer writer-write-char writer-write-string) -(defjson-writer buffer buffer-write-char buffer-write-string) \ No newline at end of file +(defjson-writer buffer buffer-write-char buffer-write-string) diff --git a/src/std/text/json/util.ss b/src/std/text/json/util.ss new file mode 100644 index 000000000..5ccc5b03e --- /dev/null +++ b/src/std/text/json/util.ss @@ -0,0 +1,120 @@ +;;; -*- Gerbil -*- +;;; ̧© vyzo, fare +;;; json utilities +(import + :gerbil/gambit/ports + :std/io :std/misc/ports :std/misc/process + :std/iter :std/misc/alist :std/misc/hash :std/misc/list :std/misc/list-builder + :std/misc/ports :std/misc/plist :std/misc/rtd :std/misc/walist + :std/sort :std/srfi/43 :std/sugar :std/values + ./env ./input ./output) + +(export #t) + +(def (read-json (input (current-input-port))) + (cond + ((input-port? input) + (read-json-object/port input (make-env))) + ((is-BufferedStringReader? input) + (read-json-object/reader (BufferedStringReader input) (make-env))) + ((is-BufferedReader? input) + (read-json-object/buffer (BufferedReader input) (make-env))) + (else + (error "Bad input source; expected input port, BufferedStringReader or BufferedReader instance" input)))) + +(def (string->json-object str) + (read-json-object/reader (open-buffered-string-reader str) (make-env))) + +(def (bytes->json-object bytes) + (read-json (open-buffered-reader bytes))) + +(def (write-json obj (output (current-output-port))) + (cond + ((output-port? output) + (write-json-object/port obj output (make-env)) + (force-output output)) + ((is-BufferedStringWriter? output) + (write-json-object/writer obj (BufferedStringWriter output) (make-env))) + ((is-BufferedWriter? output) + (write-json-object/buffer obj (BufferedWriter output) (make-env))) + ((or (is-StringWriter? output) (is-Writer? output)) + (let (output (open-buffered-string-writer output)) + (write-json-object/writer obj output (make-env)) + (&BufferedStringWriter-flush output))) + (else + (error "Bad output sink; expected output port, Writer, StringWriter or BufferedStringWriter" output)))) + +(def (json-object->string obj) + (let (buffer (open-buffered-string-writer #f)) + (write-json-object/writer obj buffer (make-env)) + (get-buffer-output-string buffer))) + +(def (json-object->bytes obj) + (let (buffer (open-buffered-writer #f)) + (write-json-object/buffer obj buffer (make-env)) + (get-buffer-output-u8vector buffer))) + +(def (trivial-class->json-object object) + (match (class->list object) + ([type . plist] + (list->hash-table + `(#|(__class . ,(symbol->string (type-name type)))|# + ,@(plist->alist plist)))))) + +(def (trivial-json-object->class klass json) + (def (find-key s) + (or (and (symbol? s) s) + (##find-interned-keyword s) + (error "invalid json key for class" s klass))) + (apply make-class-instance klass (alist->plist (map (cut map/car find-key <>) (hash->list json))))) + +(def (trivial-struct->json-object struct) + (defvalues (strukt fields) (cons->values (struct->list struct))) + (def names (cdr (assoc fields: (type-descriptor-plist strukt)))) + (def json (make-hash-table)) + (def f (if (json-symbolic-keys) identity symbol->string)) + (for ((name names) (v fields)) (hash-put! json (f name) v)) + json) + +(def (trivial-json-object->struct strukt json (defaults #f)) + (unless defaults (set! defaults (hash))) + (def names (list->vector (cdr (assoc fields: (type-descriptor-plist strukt))))) + (def positions (invert-hash<-vector names)) + (def (pos<-field f) + (def s (cond + ((symbol? f) f) + ((string? f) (##find-interned-symbol f)) + (else #f))) + (or (hash-get positions s) + (error "invalid json key for struct" f strukt json))) + (def n (vector-length names)) + (def fields (make-vector n #f)) + (def bound? (make-vector n #f)) + (for (((values k v) (in-hash json))) + (let (p (pos<-field k)) + (when (vector-ref bound? p) (error "field multiply defined" k strukt json)) + (vector-set! bound? p #t) + (vector-set! fields p v))) + (def unbounds + (with-list-builder (c) + (for ((i (in-naturals)) + (b? bound?) + (name names)) + (cond + (b? (void)) + ((hash-key? defaults name) (vector-set! fields i (hash-ref defaults name))) + (else (c name)))))) + (unless (null? unbounds) + (error "unbound fields" unbounds strukt json)) + (apply make-struct-instance strukt (vector->list fields))) + +;; Mixin for a trivial method that just lists all slots +(defclass JSON ()) +(defmethod {:json JSON} trivial-class->json-object) + +(def (pretty-json object (out #f)) + (with-output (out) + (filter-with-process + ["jq" "-M" "."] + (cut write-json object <>) + (cut copy-port <> out)))) diff --git a/src/std/values.ss b/src/std/values.ss new file mode 100644 index 000000000..b58d71eb9 --- /dev/null +++ b/src/std/values.ss @@ -0,0 +1,29 @@ +;;; -*- Gerbil -*- +;;; © fare, vyzo +;;; Utility functions and accessors for multiple values +(import ./sugar) + +(export first-value second-value nth-value + values->vector vector->values + list->values ;; NB: values->list is builtin + values->cons cons->values) + +(defrules first-value () + ((_ form) (with ((values x . _) form) x)) + ((_ form forms ...) (syntax-error "Bad syntax")) + (_ (lambda (x . _) x))) + +(defrules second-value () + ((_ form) (with ((values _ x . _) form) x)) + ((_ form forms ...) (syntax-error "Bad syntax")) + (_ (lambda (_ x . _) x))) + +(defrule (nth-value n form) (with ((values . x) form) (list-ref x n))) + +(defrule (values->vector form) (list->vector (values->list form))) +(def (vector->values v) (list->values (vector->list v))) + +(def (list->values l) (apply values l)) + +(defrule (values->cons form) (let-values (((a b) form)) (cons a b))) +(def (cons->values x) (values (car x) (cdr x)))