diff --git a/README.md b/README.md index 1fc9712..c74f50a 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,13 @@ function expecting a tuple needs an explicit conversion. The `jolt.interop`, This bridge is what makes networking (and everything else in Janet's stdlib) available to ordinary Clojure — for example, `jolt.nrepl` (below) is plain -Clojure over `janet.net/*`. Jolt also vendors -[spork/http](https://janet-lang.org/spork/api/http.html) into its binary -(`vendor/spork/http.janet`, MIT), reachable as `janet.spork.http/*` — the -`jolt.http` client and the Ring adapter in -[examples/ring-app](https://github.com/jolt-lang/examples/tree/main/ring-app) -are built on it. No `jpm install spork` required. +Clojure over `janet.net/*`. It also reaches any **jpm-installed module**: +the first reference to `janet./` requires the module from the +janet module path and caches its bindings — so after `jpm install spork`, +`janet.spork.http/*` just works. The `jolt.http` client and the Ring adapter +in [examples/ring-app](https://github.com/jolt-lang/examples/tree/main/ring-app) +are built on spork/http this way, and a project can declare the requirement +in `deps.edn` with a `:jpm/module` coordinate (see docs/tools-deps.md). ```clojure (require '[jolt.interop :as j]) diff --git a/docs/tools-deps.md b/docs/tools-deps.md index ff074bb..04a062b 100644 --- a/docs/tools-deps.md +++ b/docs/tools-deps.md @@ -107,3 +107,23 @@ a regex feature like Unicode property classes (`\p{…}`). - **Compiling deps into a binary image.** `uberscript` already produces a standalone `.clj`; baking a project's dependencies directly into a custom executable image is a heavier variant that isn't implemented. + +## Janet dependencies: `:jpm/module` + +A jolt project can depend on janet libraries. jpm owns their installation; +`deps.edn` declares the requirement and `jolt-deps` verifies it at resolve +time: + +```clojure +:deps {janet/spork-http {:jpm/module "spork/http" + :jpm/install "spork"}} +``` + +- `:jpm/module` — the janet module path that must be importable. +- `:jpm/install` (optional) — the jpm package to install when it isn't; + `jolt-deps` runs `jpm install ` once, then re-checks. Without it the + resolve fails with the install hint. + +A `:jpm/module` dep contributes no source roots. At runtime the `janet.*` +interop bridge autoloads the module on first reference +(`janet.spork.http/server`, …), so nothing else is needed. diff --git a/src/jolt/deps.janet b/src/jolt/deps.janet index 80bb790..0473a75 100644 --- a/src/jolt/deps.janet +++ b/src/jolt/deps.janet @@ -73,6 +73,26 @@ (get x :name)) (string x))) + +(defn- ensure-jpm-dep + "A :jpm/module dep declares a janet module installed through jpm (e.g. + spork/http). jolt-deps doesn't manage janet packages — jpm does — so this + just verifies the module is importable, optionally running `jpm install + <:jpm/install>` once when it isn't, and fails with the install hint + otherwise. Contributes no source roots; the janet.* bridge autoloads the + module at first use." + [lib spec] + (def mod (get spec :jpm/module)) + (defn importable? [] ((protect (require mod)) 0)) + (unless (importable?) + (when-let [pkg (get spec :jpm/install)] + (eprintf "jolt-deps: %s: jpm module %s missing — running `jpm install %s`" + (sym-name lib) mod pkg) + (os/execute ["jpm" "install" pkg] :p)) + (unless (importable?) + (errorf "%s: janet module %s is not importable. Install it with `jpm install %s` (jolt-deps leaves janet packages to jpm)." + (sym-name lib) mod (or (get spec :jpm/install) mod))))) + (defn- merge-by-name [a b] # union of symbol-keyed dictionaries, b wins (def out @{}) (each m [a b] (when (dictionary? m) (eachp [k v] m (put out (sym-name k) v)))) @@ -165,7 +185,8 @@ (and (deep= (get a :local/root) (get b :local/root)) (deep= (get a :git/url) (get b :git/url)) (deep= (get a :git/sha) (get b :git/sha)) - (deep= (get a :git/tag) (get b :git/tag)))) + (deep= (get a :git/tag) (get b :git/tag)) + (deep= (get a :jpm/module) (get b :jpm/module)))) (def queue @[]) (defn discover [lib spec base-dir] (def k (sym-name lib)) @@ -175,6 +196,8 @@ k (coord-str prev) (coord-str spec))) (do (put seen k spec) + (when (and (dictionary? spec) (get spec :jpm/module)) + (ensure-jpm-dep lib spec)) (def dir (cond (and (dictionary? spec) (get spec :git/url)) diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 56f0da4..767a919 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -13,20 +13,30 @@ # the janet/* interop bridge falls back to it inside env-less fibers. (def- module-load-env (fiber/getenv (fiber/current))) -# spork/http, VENDORED (vendor/spork/http.janet, MIT) so the jolt binary -# carries its own HTTP support: a direct import marshals the fns into the -# baked image — no runtime require, no env-bridge marshal hazards. Reaches -# the jolt layer as janet.spork.http/* via janet-bridge-extras, the second -# lookup the janet.* bridge consults after the fiber env. The Ring adapter -# (examples/ring-app) and jolt.http build on it. -(import ../../vendor/spork/http :as vendored-spork-http) -(def- janet-bridge-extras - (let [t @{} pfx "vendored-spork-http/"] - (eachp [sym entry] (curenv) - (when (and (symbol? sym) (string/has-prefix? pfx sym) (table? entry)) - (put t (string "spork.http/" (string/slice sym (length pfx))) - (get entry :value)))) - t)) +# jpm-module autoload: a janet./ reference whose module isn't +# in the env is satisfied by requiring it from the jpm module path on first +# use — (janet.spork.http/server ...) just works when spork is installed, +# and the same goes for any jpm module. Loaded bindings are cached here +# (and failures negatively cached, so a missing module errors fast). +(def- janet-bridge-extras @{}) +(def- janet-bridge-failed @{}) +(defn- bridge-autoload + "jname is spork.http/server-shaped: require spork/http, cache its public + bindings under the dotted prefix, return the one asked for (nil when the + module is missing or has no such binding)." + [jname] + (def slash (string/find "/" jname)) + (when slash + (def mod-ns (string/slice jname 0 slash)) + (unless (get janet-bridge-failed mod-ns) + (def mod-path (string/replace-all "." "/" mod-ns)) + (def r (protect (require mod-path))) + (if (r 0) + (eachp [sym entry] (r 1) + (when (and (symbol? sym) (table? entry) (not (get entry :private))) + (put janet-bridge-extras (string mod-ns "/" sym) (get entry :value)))) + (put janet-bridge-failed mod-ns true)))) + (in janet-bridge-extras jname)) (defn- sym-name? [sym-s name-str] @@ -644,15 +654,16 @@ (let [jname (if (= ns "janet") name (string (string/slice ns 6) "/" name)) # worker fibers may carry no env (fiber/new without :e inherit) # — fall back to the env captured at module load - # three-step resolution: the runtime fiber's env (when it + # four-step resolution: the runtime fiber's env (when it # has one), the evaluator's module env (worker/connection # fibers carry a foreign or empty env — net/server handler - # fibers resolve janet/struct through here), then the - # vendored-module registry (spork.http/*, marshaled fns) + # fibers resolve janet/struct through here), the autoload + # cache, then a jpm-module require on first miss entry (or (when-let [fe (fiber/getenv (fiber/current))] (in fe (symbol jname))) (in module-load-env (symbol jname)) - (in janet-bridge-extras jname))] + (in janet-bridge-extras jname) + (bridge-autoload jname))] (if (not (nil? entry)) (if (table? entry) (entry :value) entry) (error (string "Unable to resolve Janet symbol: " jname)))) diff --git a/test/integration/deps-resolve-test.janet b/test/integration/deps-resolve-test.janet index 1b308b9..5a22b88 100644 --- a/test/integration/deps-resolve-test.janet +++ b/test/integration/deps-resolve-test.janet @@ -125,6 +125,32 @@ ) (rmrf gbase) +# --- :jpm/module deps: janet libraries installed through jpm ----------------- +# Verification only (jpm owns installation): an importable module passes and +# contributes no roots; a missing one errors with the install hint. jpm/pm is +# always importable wherever jpm itself runs (CI included). +(do + (def jbase (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-deps-jpm-" (os/time))) + (mkdirs (string jbase "/src")) + (spit (string jbase "/deps.edn") + `{:paths ["src"] :deps {janet/jpm-pm {:jpm/module "jpm/pm"}}}`) + (def cwd (os/cwd)) + (os/cd jbase) + (def roots (deps/resolve-deps "deps.edn" (string jbase "/jpm_tree"))) + (os/cd cwd) + (check "jpm module dep resolves" true (not (nil? roots))) + (check "jpm module contributes no roots" 1 (length roots)) + + (spit (string jbase "/deps.edn") + `{:paths ["src"] :deps {janet/nope {:jpm/module "no/such-module-xyz"}}}`) + (os/cd jbase) + (def r (protect (deps/resolve-deps "deps.edn" (string jbase "/jpm_tree")))) + (os/cd cwd) + (check "missing jpm module errors" false (r 0)) + (check "error carries the install hint" true + (not (nil? (string/find "jpm install" (string (r 1)))))) + (rmrf jbase)) + (if (> fails 0) (error (string "deps-resolve-test: " fails " failing check(s)")) (print "\nAll deps-resolve tests passed!")) diff --git a/vendor/spork/http.janet b/vendor/spork/http.janet deleted file mode 100644 index 7349776..0000000 --- a/vendor/spork/http.janet +++ /dev/null @@ -1,531 +0,0 @@ -# Vendored from janet-lang/spork (MIT license) @ 3bdcf58 -# for the janet.spork.http/* bridge — see evaluator.janet. Do not edit here. -### -### http.janet -### -### Pure Janet HTTP/1.1 parser, client, and server. -### - -(def- chunk-size (* 16 4096)) - -(defn- pre-pop - "Remove n bytes from front of buffer" - [buf n] - (buffer/blit buf buf 0 n) - (buffer/popn buf n)) - -(def- http-grammar - ~{:request-status (* :method :ws :path :ws "HTTP/1." :d :any-ws :rn) - :response-status (* "HTTP/1." :d :ws (/ ':d+ ,scan-number) - :ws '(some :printable) :rn) - :ws (some (set " \t")) - :any-ws (any (set " \t")) - :rn "\r\n" - :method '(some (range "AZ")) - :path-chr (range "az" "AZ" "09" "!!" "$9" ":;" "==" "?@" "~~" "__") - :path '(some :path-chr) - :printable (range "\x20~" "\t\t") - :headers (* (any :header) :rn) - # lower case header names since http headers are case-insensitive - :header-name (/ '(some (range "\x219" ";~")) ,string/ascii-lower) - :header-value '(any :printable) - :header (* :header-name ":" :any-ws :header-value :rn)}) - -(def request-peg - "PEG for parsing HTTP requests" - (peg/compile - (table/to-struct - (merge {:main ~(* :request-status :headers)} - http-grammar)))) - -(def response-peg - "PEG for parsing HTTP responses" - (peg/compile - (table/to-struct (merge - {:main ~(* :response-status :headers)} - http-grammar)))) - -(defn- accum-key-values - "Accumulate key-value pairs based on arg index (even = key, odd = value) into a table and combine - duplicate keys into arrays of values (rather than overwriting). Used for both query strings and headers." - [& args] - (def tab @{}) - (loop [i :range [0 (length args) 2] - :let [k (get args i) v (get args (+ 1 i))]] - (if-let [item (in tab k)] - (if (array? item) - (array/push item v) - (put tab k @[item v])) - (put tab k v))) - tab) - -(defn- read-header - "Read an HTTP header from a stream." - [conn buf peg key1 key2] - (var head nil) - (var last-index 0) - (forever - (when-let [end (string/find "\r\n\r\n" buf last-index)] - (set head - (if-let [matches (peg/match peg buf)] - (let [[a b] matches - headers (accum-key-values ;(array/remove matches 0 2))] - @{:headers headers - :connection conn - :buffer (pre-pop buf (+ 4 end)) - :head-size (+ 4 end) - key1 a - key2 b}) - :error)) - (break)) - (set last-index (max 0 (- (length buf) 4))) - (unless (:read conn chunk-size buf) - (set head :error) - (break))) - head) - - -(def query-string-grammar - "Grammar that parses a query string (sans url path and ? character) and returns a table." - (peg/compile - ~{:qchar (+ (* "%" (/ (number (* :h :h) 16) ,string/from-bytes)) (* "+" (constant " "))) - :kchar (+ :qchar (* (not (set "&=;")) '1)) - :vchar (+ :qchar (* (not (set "&;")) '1)) - :key (accumulate (some :kchar)) - :value (accumulate (any :vchar)) - :entry (* :key (+ (* "=" :value) (constant true)) (+ (set ";&") -1)) - :main (/ (any :entry) ,accum-key-values)})) - -(defn read-request - ``Read an HTTP request header from a connection. Returns a table with the following keys: - * `:headers` - table mapping header names to header values. Header names are lowercase. - * `:connection` - the connection stream for the header. - * `:buffer` - the buffer instance that may contain extra bytes. - * `:head-size` - the number of bytes used by the header. - * `:method` - the HTTP method used. - * `:path` - the path of the resource requested. - - The following keys are also present, but omitted if the user passes a truthy parameter to `no-query`. - * `:route` - path of the resource requested without query string. - * `:query-string` - segment of HTTP path after first ? character. - * `:query` - the query string parsed into a table. Supports a single string value - for every string key, and any query parameters that aren't given a value are mapped to true. - - Note that data is read in chunks and any data after the header terminator is - stored in `:buffer`.`` - [conn buf &opt no-query] - (def head (read-header conn buf request-peg :method :path)) - (if (= :error head) (break head)) - - # Parse query string separately - (unless no-query - (def fullpath (get head :path)) - (def qloc (string/find "?" fullpath)) - (def path (if qloc (string/slice fullpath 0 qloc) fullpath)) - (def qs (if qloc (string/slice fullpath (inc qloc)) nil)) - (put head :route path) - (put head :query-string qs) - (when qs - (when-let [m (peg/match query-string-grammar qs)] - (put head :query (first m))))) - - head) - -(defn read-response - ``Read an HTTP response header from a connection. Returns a table with the following keys: - * `:headers` - table mapping header names to header values. Header names are lowercase. - * `:connection` - the connection stream for the header. - * `:buffer` - the buffer instance that may contain extra bytes. - * `:head-size` - the number of bytes used by the header. - * `:status` - the HTTP status code. - * `:message` - the HTTP status message. - - Note that data is read in chunks and any data after the header terminator is - stored in `:buffer`.`` - [conn buf] - (read-header conn buf response-peg :status :message)) - -(def status-messages - "Mapping of HTTP status codes to their status message." - {100 "Continue" - 101 "Switching Protocols" - 102 "Processing" - 200 "OK" - 201 "Created" - 202 "Accepted" - 203 "Non-Authoritative Information" - 204 "No Content" - 205 "Reset Content" - 206 "Partial Content" - 207 "Multi-Status" - 208 "Already Reported" - 226 "IM Used" - 300 "Multiple Choices" - 301 "Moved Permanently" - 302 "Found" - 303 "See Other" - 304 "Not Modified" - 305 "Use Proxy" - 307 "Temporary Redirect" - 308 "Permanent 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 "Payload Too Large" - 414 "URI Too Long" - 415 "Unsupported Media Type" - 416 "Range Not Satisfiable" - 417 "Expectation Failed" - 421 "Misdirected Request" - 422 "Unprocessable Entity" - 423 "Locked" - 424 "Failed Dependency" - 426 "Upgrade Required" - 428 "Precondition Required" - 429 "Too Many Requests" - 431 "Request Header Fields Too Large" - 451 "Unavailable For Legal Reasons" - 500 "Internal Server Error" - 501 "Not Implemented" - 502 "Bad Gateway" - 503 "Service Unavailable" - 504 "Gateway Timeout" - 505 "HTTP Version Not Supported" - 506 "Variant Also Negotiates" - 507 "Insufficient Storage" - 508 "Loop Detected" - 510 "Not Extended" - 511 "Network Authentication Required"}) - -(defn- write-body - "Write the body of an HTTP request, adding Content-Length header - or Transfer-Encoding: chunked" - [conn buf body] - (cond - (nil? body) - (do - (buffer/push buf "\r\n") - (:write conn buf)) - - (bytes? body) - (do - (buffer/format buf "Content-Length: %d\r\n\r\n%V" (length body) body) - (:write conn buf)) - - # default - iterate chunks - (do - (buffer/format buf "Transfer-Encoding: chunked\r\n\r\n") - (each chunk body - (assert (bytes? chunk) "expected byte chunk") - (buffer/format buf "%x\r\n%V\r\n" (length chunk) chunk) - (:write conn buf) - (buffer/clear buf)) - (buffer/format buf "0\r\n\r\n") - (:write conn buf))) - (buffer/clear buf)) - -(defn- read-until - "Read single bytes from connection into buffer until the provided byte - sequence is found within it. The buffer need not be empty. Returns the number - of bytes from the start of the buffer until the substring." - [conn buf needle &opt start-index] - (default start-index 0) - (when-let [pos (peg/find needle buf start-index)] - (break pos)) - (prompt :exit - (forever - (unless (:read conn 1 buf) - (error "end of stream")) - (when-let [pos (peg/find needle buf start-index)] - (return :exit pos))))) - -(defn read-body - "Given a request, read the HTTP body from the connection. Returns the body as a buffer. - If the request has no body, returns nil." - [req] - (when-let [body (in req :body)] (break body)) - (def headers (in req :headers)) - - # In place content - (when-let [cl (in headers "content-length")] - (def {:buffer buf - :connection conn} req) - (def content-length (scan-number cl)) - (def remaining (- content-length (length buf))) - (when (pos? remaining) - (:chunk conn remaining buf)) - (put req :body buf) - (break buf)) - - # event stream aka SSE - (when (-?>> (in headers "content-type") - (string/has-prefix? "text/event-stream")) - (def {:buffer buf - :connection conn} req) - (read-until conn buf "\n\n") - (put req :body buf) - (break buf)) - - # Chunked encoding - # TODO: The specification can have multiple transfer encodings so this - # precise string matching may not work for every case. - (when (= (in headers "transfer-encoding") "chunked") - (def {:buffer buf - :connection conn} req) - (def body (buffer/new chunk-size)) - (var i 0) - (forever - (def chunk-length-end-pos (read-until conn buf "\r\n" i)) - (var chunk-length (scan-number (slice buf i chunk-length-end-pos) 16)) - (when (zero? chunk-length) - (read-until conn buf "\r\n" (+ 2 chunk-length-end-pos)) - (break)) - # If there's any data already read, blit that over first. - (let [leftover-start (+ chunk-length-end-pos 2) - leftover (- (length buf) leftover-start) - blit-amount (min leftover chunk-length)] # prevent overreading - (unless (zero? blit-amount) - (buffer/blit body buf -1 leftover-start (+ leftover-start blit-amount)) - (-= chunk-length blit-amount)) - (set i (+ leftover-start blit-amount))) - (if (= i (length buf)) - # Basic case: the buffer has been exhausted and hereonout we can read - # from the socket directly. - (do - (:chunk conn chunk-length body) - (unless (:read conn 2 buf) # trailing CRLF (not included in chunk length proper) - (error "end of stream")) - # Clear buffer out. We ain't gonna need it no more. - (buffer/clear buf) - (set i 0)) - # Alternatively, the pre-read data in the buffer was plentiful and we - # just managed to copy an entire chunk out of it. Just increment past - # the CRLF, if it's not already there, and proceed to loop again. - (set i (+ (read-until conn buf "\r\n" i) 2)))) - (put req :body body) - (break body)) - - # no body, just return nil - nil) - -(defn send-response - ``Send an HTTP response over a connection. Will automatically use chunked - encoding if body is not a byte sequence. `response` should be a table - with the following keys: - - * `:headers` - optional headers to write - * `:status` - integer status code to write - * `:body` - optional byte sequence or iterable (for chunked body) - for returning contents. The iterable can be lazy, i.e. for streaming - data.`` - [conn response &opt buf] - (default buf @"") - (def status (get response :status 200)) - (def message (in status-messages status)) - (buffer/format buf "HTTP/1.1 %d %s\r\n" status message) - (def headers (get response :headers {})) - - (eachp [k v] headers - # Values can be lists when representing duplicate headers (e.g.: multiple "Set-Cookie" entries) - (if (or (tuple? v) (array? v)) - (each ve v (buffer/format buf "%V: %V\r\n" k ve)) - (buffer/format buf "%V: %V\r\n" k v))) - - (write-body conn buf (in response :body))) - -### -### Server Middleware -### - -(defn- bytes-to-mw - [b] - (fn mw [&] {:status 200 :body b})) - -(defn middleware - "Coerce any type to http middleware" - [x] - (case (type x) - :function x - :number (let [msg (get status-messages x)] - (assert msg (string "unknown http status code when making middleware: " x)) - (fn mw [&] {:status x :body msg})) - :string (bytes-to-mw x) - :buffer (bytes-to-mw x) - (fn mw [&] x))) - -(defn router - "Creates a router middleware. A router will dispatch to different routes based on - the URL path." - [routes] - (fn router-mw [req] - (def r (or - (get routes (get req :route)) - (get routes :default))) - (if r ((middleware r) req) {:status 404 :body "Not Found"}))) - -(defn logger - "Creates a logging middleware. The logger middleware prints URL route, return status, and elapsed request time." - [nextmw] - (fn logger-mw [req] - (def {:path path - :method method} req) - (def start-clock (os/clock)) - (def ret (nextmw req)) - (def end-clock (os/clock)) - (def elapsed (string/format "%.3f" (* 1000 (- end-clock start-clock)))) - (def status (or (get ret :status) 200)) - (print method " " status " " path " elapsed " elapsed "ms") - (flush) - ret)) - -(def cookie-grammar - "Grammar to parse a cookie header to a series of keys and values." - (peg/compile - {:content '(some (if-not (set "=;") 1)) - :eql "=" - :sep '(between 1 2 (set "; ")) - :main '(some (* (<- :content) :eql (<- :content) (? :sep)))})) - -(defn cookies - "Parses cookies into the table under :cookies key" - [nextmw] - (fn cookie-mw [req] - (-> req - (put :cookies - (or (-?>> [:headers "cookie"] - (get-in req) - (peg/match cookie-grammar) - (apply table)) - {})) - nextmw))) - -### -### Server boilerplate -### - -(defn server-handler - ``A simple connection handler for an HTTP server. - When a connection is accepted. Call this with a handler - function to handle the connect. The handler will be called - with one argument, the request table, which will contain the - following keys: - * `:head-size` - number of bytes in the http header. - * `:headers` - table mapping header names to header values. - * `:connection` - the connection stream for the header. - * `:buffer` - the buffer instance that may contain extra bytes. - * `:path` - HTTP path. - * `:method` - HTTP method, as a string.`` - [conn handler] - (def handler (middleware handler)) - (defer (:close conn) - - # Get request header - (def buf (buffer/new chunk-size)) - (def req (read-request conn buf)) - - # Handle bad request - (when (= :error req) - (send-response conn {:status 400} (buffer/clear buf)) - (break)) - - # Add some extra keys to the request - (put req :connection conn) - - # Do something with request header - (def response (handler req)) - - # Now send back response - (send-response conn response @""))) - -(defn server - "Makes a simple http server. By default it binds to 0.0.0.0:8000, - returns a new server stream. - Simply wraps http/server-handler with a net/server." - [handler &opt host port] - (default host "0.0.0.0") - (default port 8000) - (defn new-handler - [conn] - (server-handler conn handler)) - (net/server host port new-handler)) - -### -### HTTP Client -### - -(def- url-peg-source - ~{:main (* (+ :https :http) :fqdn :port :path) - :https (* (constant "https") "https://") - :http (* (constant "http") "http://") - :fqdn '(some (range "az" "AZ" "09" ".." "--")) - :port (+ (* ":" ':d+) (constant nil)) - :path-chr (range "az" "AZ" "09" "!!" "$9" ":;" "==" "?@" "~~" "__") - :path (+ '(some :path-chr) (constant "/"))}) - -(def url-grammar - "Grammar to parse a URL into scheme, domain, port, and path. Supports - both http:// and https:// protocols. Returns [scheme host port path]." - (peg/compile url-peg-source)) - -(defn request - ``Make an HTTP request to a server. - Returns a table containing response information. - * `:head-size` - number of bytes in the http header - * `:headers` - table mapping header names to header values. Header names are lowercase. - * `:connection` - the connection stream for the header. - * `:buffer` - the buffer instance that may contain extra bytes. - * `:status` - HTTP status code as an integer. - * `:message` - HTTP status message. - * `:body` - Bytes of the response body. - - Options: - * `:body` - Request body content - * `:headers` - Request headers table - * `:stream-factory` - Function to create connection stream. Defaults to net/connect. - Signature: (stream-factory host port stream-opts) - * `:stream-opts` - Options table passed to stream-factory`` - [method url &keys - {:body body - :headers headers - :stream-factory stream-factory - :stream-opts stream-opts}] - (def x (peg/match url-grammar url)) - (assert x (string "invalid url: " url)) - (def [scheme host raw-port path] x) - # Default port based on scheme - (def port (or raw-port (if (= scheme "https") "443" "80"))) - (def buf @"") - (buffer/format buf "%s %s HTTP/1.1\r\nHost: %s:%s\r\n" method path host port) - (when headers - (eachp [k v] headers - (buffer/format buf "%s: %s\r\n" k v))) - - # Use custom stream-factory or default to net/connect - (let [make-conn (or stream-factory net/connect) - conn (if stream-opts - (make-conn host port stream-opts) - (make-conn host port))] - (with [conn conn] - - # Make request - (write-body conn buf body) - - # Parse response pure janet - (def res (read-response conn buf)) - (when (= :error res) (error res)) - # HEAD responses have no body per HTTP spec, skip read-body - (unless (= method "HEAD") - (read-body res)) - - # TODO - handle redirects with Location header - res)))