diff --git a/cohttp-eio.opam b/cohttp-eio.opam index 2f635495ac..b4d468cd12 100644 --- a/cohttp-eio.opam +++ b/cohttp-eio.opam @@ -21,6 +21,7 @@ bug-reports: "https://github.com/mirage/ocaml-cohttp/issues" depends: [ "dune" {>= "3.0"} "base-domains" + "cohttp" {= version} "eio" {>= "0.7"} "eio_main" {with-test} "mdx" {with-test} diff --git a/cohttp-eio/examples/client1.ml b/cohttp-eio/examples/client1.ml index 44d41a2133..5ff560bd62 100644 --- a/cohttp-eio/examples/client1.ml +++ b/cohttp-eio/examples/client1.ml @@ -1,6 +1,15 @@ open Cohttp_eio +let () = Logs.set_reporter (Logs_fmt.reporter ()) +and () = Logs.Src.set_level Cohttp_eio.src (Some Debug) + let () = Eio_main.run @@ fun env -> - let res = Client.get env ~host:"www.example.org" "/" in - print_string @@ Client.read_fixed res + Eio.Switch.run @@ fun sw -> + match Client.get ~sw env#net (Uri.of_string "http://example.com") with + | Result.Ok (resp, body) when Http.Status.compare resp.status `OK = 0 -> + Fmt.string Format.std_formatter + @@ Eio.Buf_read.(take_all @@ of_flow ~max_size:max_int body) + | Result.Ok (resp, _) -> + Fmt.epr "Unexpected HTTP status: %a" Http.Status.pp resp.status + | Result.Error e -> Fmt.epr "HTTP error: %s" e diff --git a/cohttp-eio/examples/client_timeout.ml b/cohttp-eio/examples/client_timeout.ml index 6072b356b3..e8d9a41b05 100644 --- a/cohttp-eio/examples/client_timeout.ml +++ b/cohttp-eio/examples/client_timeout.ml @@ -1,18 +1,16 @@ -open Eio open Cohttp_eio let () = Eio_main.run @@ fun env -> - Switch.run @@ fun sw -> (* Increment/decrement this value to see success/failure. *) let timeout_s = 0.01 in Eio.Time.with_timeout env#clock timeout_s (fun () -> - let host, port = ("www.example.org", 80) in - let he = Unix.gethostbyname host in - let addr = `Tcp (Eio_unix.Ipaddr.of_unix he.h_addr_list.(0), port) in - let conn = Net.connect ~sw env#net addr in - let res = Client.get ~conn ~port env ~host "/" in - Client.read_fixed res |> Result.ok) + Eio.Switch.run @@ fun sw -> + match Client.get env#net ~sw (Uri.of_string "http://www.example.org") with + | Result.Error e -> Result.Error (`Fatal e) + | Result.Ok (_, body) -> + Eio.Buf_read.(of_flow ~max_size:max_int body |> take_all) |> Result.ok) |> function | Ok s -> print_string s - | Error `Timeout -> print_string "Connection timed out" + | Error (`Fatal e) -> Fmt.epr "fatal error: %s@." e + | Error `Timeout -> Fmt.epr "Connection timed out@." diff --git a/cohttp-eio/examples/docker_client.ml b/cohttp-eio/examples/docker_client.ml index 60a6f38f6e..c69309cb1d 100644 --- a/cohttp-eio/examples/docker_client.ml +++ b/cohttp-eio/examples/docker_client.ml @@ -5,16 +5,25 @@ module Client = Cohttp_eio.Client module Response = Http.Response module Status = Http.Status +let () = Logs.set_reporter (Logs_fmt.reporter ()) +and () = Logs.Src.set_level Cohttp_eio.src (Some Debug) + let () = Eio_main.run @@ fun env -> - Switch.run @@ fun sw -> - let addr = `Unix "/var/run/docker.sock" in - let conn = Net.connect ~sw env#net addr in - let res = Client.get ~conn ~host:"docker" env "/version" in - let code = fst res |> Response.status |> Status.to_int in - Printf.printf "Response code: %d\n" code; - Printf.printf "Headers: %s\n" - (fst res |> Response.headers |> Http.Header.to_string); - let body = Client.read_fixed res in - Printf.printf "Body of length: %d\n" (String.length body); - print_endline ("Received body\n" ^ body) + Eio.Switch.run @@ fun sw -> + match + Client.get env#net ~sw + @@ Uri.make ~scheme:"httpunix" ~host:"/var/run/docker.sock" ~path:"/version" + () + with + | Result.Error error -> + Printf.eprintf "fatal error: %sn" error; + exit 1 + | Result.Ok (response, body) -> + let code = response |> Response.status |> Status.to_int in + Printf.printf "Response code: %d\n" code; + Printf.printf "Headers: %s\n" + (response |> Response.headers |> Http.Header.to_string); + let body = Eio.Buf_read.(of_flow ~max_size:max_int body |> take_all) in + Printf.printf "Body of length: %d\n" (String.length body); + print_endline ("Received body\n" ^ body) diff --git a/cohttp-eio/examples/dune b/cohttp-eio/examples/dune index e5f3acc484..22381a4c19 100644 --- a/cohttp-eio/examples/dune +++ b/cohttp-eio/examples/dune @@ -1,6 +1,6 @@ (executables (names server1 client1 docker_client client_timeout) - (libraries cohttp-eio eio_main eio.unix unix)) + (libraries cohttp-eio eio_main eio.unix fmt unix logs.fmt)) (alias (name runtest) diff --git a/cohttp-eio/examples/server1.ml b/cohttp-eio/examples/server1.ml index 9f767d8e8e..0e9b853ec7 100644 --- a/cohttp-eio/examples/server1.ml +++ b/cohttp-eio/examples/server1.ml @@ -28,19 +28,26 @@ let text = was coming to, but it was too dark to see anything; then she looked at the \ sides of the well, and noticed that they were filled with cupboards......" -open Cohttp_eio +let () = Logs.set_reporter (Logs_fmt.reporter ()) +and () = Logs.Src.set_level Cohttp_eio.src (Some Debug) -let app : Server.request -> Server.response = - fun ((req, _, _) : Server.request) -> - match Http.Request.resource req with - | "/" -> Server.text_response text - | "/html" -> Server.html_response text - | _ -> Server.not_found_response +let handler request _body = + match Http.Request.resource request with + | "/" -> (Http.Response.make (), Cohttp_eio.Body.of_string text) + | "/html" -> + ( Http.Response.make + ~headers:(Http.Header.of_list [ ("content-type", "text/html") ]) + (), + (* Use a plain flow to test chunked encoding *) + Eio.Flow.string_source text ) + | _ -> (Http.Response.make ~status:`Not_found (), Cohttp_eio.Body.of_string "") let () = let port = ref 8080 in Arg.parse [ ("-p", Arg.Set_int port, " Listening port number(8080 by default)") ] ignore "An HTTP/1.1 server"; - - Eio_main.run @@ fun env -> Server.run ~port:!port env app + Eio_main.run @@ fun env -> + Eio.Switch.run @@ fun sw -> + let server = Cohttp_eio.Server.make env#net ~sw ~port:!port handler in + Cohttp_eio.Server.run server diff --git a/cohttp-eio/src/body.ml b/cohttp-eio/src/body.ml index 60f0291637..2dee1cad0a 100644 --- a/cohttp-eio/src/body.ml +++ b/cohttp-eio/src/body.ml @@ -1,267 +1,10 @@ -module Buf_read = Eio.Buf_read -module Buf_write = Eio.Buf_write - -type t = - | Fixed of string - | Chunked of chunk_writer - | Custom of (Buf_write.t -> unit) - | Empty - -and chunk_writer = { - body_writer : (chunk -> unit) -> unit; - trailer_writer : (Http.Header.t -> unit) -> unit; -} - -and chunk = Chunk of chunk_body | Last_chunk of chunk_extension list - -and chunk_body = { - size : int; - data : string; - extensions : chunk_extension list; -} - -and chunk_extension = { name : string; value : string option } - -let pp_chunk_extension fmt = - Fmt.( - vbox - @@ list ~sep:Fmt.semi - @@ record - [ - Fmt.field "name" (fun ext -> ext.name) Fmt.string; - Fmt.field "value" (fun ext -> ext.value) Fmt.(option string); - ]) - fmt - -let pp_chunk fmt = function - | Chunk chunk -> - Fmt.( - record - [ - Fmt.field "size" (fun t -> t.size) Fmt.int; - Fmt.field "data" (fun t -> t.data) Fmt.string; - Fmt.field "extensions" (fun t -> t.extensions) pp_chunk_extension; - ]) - fmt chunk - | Last_chunk extensions -> pp_chunk_extension fmt extensions - -(* Chunked encoding parser *) - -let hex_digit = function - | '0' .. '9' -> true - | 'a' .. 'f' -> true - | 'A' .. 'F' -> true - | _ -> false - -let quoted_char = - let open Buf_read.Syntax in - let+ c = Buf_read.any_char in - match c with - | ' ' | '\t' | '\x21' .. '\x7E' -> c - | c -> failwith (Printf.sprintf "Invalid escape \\%C" c) - -(*-- qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text -- *) -let qdtext = function - | ('\t' | ' ' | '\x21' | '\x23' .. '\x5B' | '\x5D' .. '\x7E') as c -> c - | c -> failwith (Printf.sprintf "Invalid quoted character %C" c) - -(*-- quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE --*) -let quoted_string r = - Buf_read.char '"' r; - let buf = Buffer.create 100 in - let rec aux () = - match Buf_read.any_char r with - | '"' -> Buffer.contents buf - | '\\' -> - Buffer.add_char buf (quoted_char r); - aux () - | c -> - Buffer.add_char buf (qdtext c); - aux () - in - aux () - -let optional c x r = - let c2 = Buf_read.peek_char r in - if Some c = c2 then ( - Buf_read.consume r 1; - Some (x r)) - else None - -(*-- https://datatracker.ietf.org/doc/html/rfc7230#section-4.1 --*) -let chunk_ext_val = - let open Buf_read.Syntax in - let* c = Buf_read.peek_char in - match c with Some '"' -> quoted_string | _ -> Rwer.token - -let rec chunk_exts r = - let c = Buf_read.peek_char r in - match c with - | Some ';' -> - Buf_read.consume r 1; - let name = Rwer.token r in - let value = optional '=' chunk_ext_val r in - { name; value } :: chunk_exts r - | _ -> [] - -let chunk_size = - let open Buf_read.Syntax in - let* sz = Rwer.take_while1 hex_digit in - try Buf_read.return (Format.sprintf "0x%s" sz |> int_of_string) - with _ -> failwith (Format.sprintf "Invalid chunk_size: %s" sz) - -(* Be strict about headers allowed in trailer headers to minimize security - issues, eg. request smuggling attack - - https://portswigger.net/web-security/request-smuggling - Allowed headers are defined in 2nd paragraph of - https://datatracker.ietf.org/doc/html/rfc7230#section-4.1.2 *) -let is_trailer_header_allowed h = - match String.lowercase_ascii h with - | "transfer-encoding" | "content-length" | "host" - (* Request control headers are not allowed. *) - | "cache-control" | "expect" | "max-forwards" | "pragma" | "range" | "te" - (* Authentication headers are not allowed. *) - | "www-authenticate" | "authorization" | "proxy-authenticate" - | "proxy-authorization" - (* Cookie headers are not allowed. *) - | "cookie" | "set-cookie" - (* Response control data headers are not allowed. *) - | "age" | "expires" | "date" | "location" | "retry-after" | "vary" | "warning" - (* Headers to process the payload are not allowed. *) - | "content-encoding" | "content-type" | "content-range" | "trailer" -> - false - | _ -> true - -(* Request indiates which headers will be sent in chunk trailer part by - specifying the headers in comma separated value in 'Trailer' header. *) -let request_trailer_headers headers = - match Http.Header.get headers "Trailer" with - | Some v -> List.map String.trim @@ String.split_on_char ',' v - | None -> [] - -(* Chunk decoding algorithm is explained at - https://datatracker.ietf.org/doc/html/rfc7230#section-4.1.3 *) -let chunk (total_read : int) (headers : Http.Header.t) = - let open Buf_read.Syntax in - let* sz = chunk_size in - match sz with - | sz when sz > 0 -> - let* extensions = chunk_exts <* Rwer.crlf in - let* data = Buf_read.take sz <* Rwer.crlf in - Buf_read.return @@ `Chunk (sz, data, extensions) - | 0 -> - let* extensions = chunk_exts <* Rwer.crlf in - (* Read trailer headers if any and append those to request headers. - Only headers names appearing in 'Trailer' request headers and "allowed" trailer - headers are appended to request. - The spec at https://datatracker.ietf.org/doc/html/rfc7230#section-4.1.3 - specifies that 'Content-Length' and 'Transfer-Encoding' headers must be - updated. *) - let* trailer_headers = Rwer.http_headers in - let request_trailer_headers = request_trailer_headers headers in - let trailer_headers = - List.filter - (fun (name, _) -> - List.mem name request_trailer_headers - && is_trailer_header_allowed name) - (Http.Header.to_list trailer_headers) - in - let request_headers = - List.fold_left - (fun h (key, v) -> Http.Header.add h key v) - headers trailer_headers - in - (* Remove either just the 'chunked' from Transfer-Encoding header value or - remove the header entirely if value is empty. *) - let te_header = "Transfer-Encoding" in - let request_headers = - match Http.Header.get request_headers te_header with - | Some header_value -> - let new_header_value = - String.split_on_char ',' header_value - |> List.map String.trim - |> List.filter (fun v -> - let v = String.lowercase_ascii v in - not (String.equal v "chunked")) - |> String.concat "," - in - if String.length new_header_value > 0 then - Http.Header.replace request_headers te_header new_header_value - else Http.Header.remove request_headers te_header - | None -> assert false - in - (* Remove 'Trailer' from request headers. *) - let headers = Http.Header.remove request_headers "Trailer" in - (* Add Content-Length header *) - let headers = - Http.Header.add headers "Content-Length" (string_of_int total_read) - in - Buf_read.return @@ `Last_chunk (extensions, headers) - | sz -> failwith (Format.sprintf "Invalid chunk size: %d" sz) - -let read_chunked reader headers f = - match Http.Header.get_transfer_encoding headers with - | Http.Transfer.Chunked -> - let total_read = ref 0 in - let rec chunk_loop f = - let chunk = chunk !total_read headers reader in - match chunk with - | `Chunk (size, data, extensions) -> - f (Chunk { size; data; extensions }); - total_read := !total_read + size; - (chunk_loop [@tailcall]) f - | `Last_chunk (extensions, headers) -> - f (Last_chunk extensions); - Some headers - in - chunk_loop f - | _ -> None - -(* https://datatracker.ietf.org/doc/html/rfc7230#section-4.1 *) -let write_chunked ?(write_chunked_trailers = false) writer chunk_writer = - let write_extensions exts = - List.iter - (fun { name; value } -> - let v = - match value with None -> "" | Some v -> Printf.sprintf "=%s" v - in - Buf_write.string writer (Printf.sprintf ";%s%s" name v)) - exts - in - let write_body = function - | Chunk { size; data; extensions = exts } -> - Buf_write.string writer (Printf.sprintf "%X" size); - write_extensions exts; - Buf_write.string writer "\r\n"; - Buf_write.string writer data; - Buf_write.string writer "\r\n" - | Last_chunk exts -> - Buf_write.string writer "0"; - write_extensions exts; - Buf_write.string writer "\r\n" - in - chunk_writer.body_writer write_body; - if write_chunked_trailers then - chunk_writer.trailer_writer (Rwer.write_headers writer); - Buf_write.string writer "\r\n" - -let write_body ?write_chunked_trailers writer body = - match body with - | Fixed s -> Buf_write.string writer s - | Chunked chunk_writer -> - write_chunked ?write_chunked_trailers writer chunk_writer - | Custom f -> f writer - | Empty -> () - -let add_content_length requires_content_length headers body : Http.Header.t = - let content_length_hdr = "Content-Length" in - if requires_content_length && not (Http.Header.mem headers content_length_hdr) - then - match body with - | Fixed s -> - String.length s - |> string_of_int - |> Http.Header.add headers content_length_hdr - | Empty -> Http.Header.add headers content_length_hdr "0" - | _ -> headers - else headers +type t = Eio.Flow.source +type Eio.Flow.read_method += String of string + +let of_string s = + let source = Eio.Flow.string_source s in + object + inherit Eio.Flow.source + method! read_methods = String s :: source#read_methods + method read_into dst = source#read_into dst + end diff --git a/cohttp-eio/src/client.ml b/cohttp-eio/src/client.ml index a9942f1ef6..0a91dfc061 100644 --- a/cohttp-eio/src/client.ml +++ b/cohttp-eio/src/client.ml @@ -1,145 +1,96 @@ -module Buf_read = Eio.Buf_read -module Buf_write = Eio.Buf_write +include Client_intf +open Utils -type response = Http.Response.t * Buf_read.t -type host = string -type port = int -type resource_path = string -type 'a env = < net : Eio.Net.t ; .. > as 'a +module Make (Base : BASE) = struct + include Base -type ('a, 'b) body_disallowed_call = - ?pipeline_requests:bool -> - ?version:Http.Version.t -> - ?headers:Http.Header.t -> - ?conn:(#Eio.Flow.two_way as 'a) -> - ?port:port -> - 'b env -> - host:host -> - resource_path -> - response -(** [body_disallowed_call] denotes HTTP client calls where a request is not - allowed to have a request body. *) - -type ('a, 'b) body_allowed_call = - ?pipeline_requests:bool -> - ?version:Http.Version.t -> - ?headers:Http.Header.t -> - ?body:Body.t -> - ?conn:(#Eio.Flow.two_way as 'a) -> - ?port:port -> - 'b env -> - host:host -> - resource_path -> - response - -(* Request line https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.1 *) -let write_request pipeline_requests request writer body = - let headers = - Body.add_content_length - (Http.Request.requires_content_length request) - (Http.Request.headers request) - body - in - let headers = Http.Header.clean_dup headers in - let headers = Http.Header.Private.move_to_front headers "Host" in - let meth = Http.Method.to_string @@ Http.Request.meth request in - let version = Http.Version.to_string @@ Http.Request.version request in - Buf_write.string writer meth; - Buf_write.char writer ' '; - Buf_write.string writer @@ Http.Request.resource request; - Buf_write.char writer ' '; - Buf_write.string writer version; - Buf_write.string writer "\r\n"; - Rwer.write_headers writer headers; - Buf_write.string writer "\r\n"; - Body.write_body ~write_chunked_trailers:true writer body; - if not pipeline_requests then Buf_write.flush writer - -(* response parser *) - -let is_digit = function '0' .. '9' -> true | _ -> false - -let status_code = - let open Rwer in - let open Buf_read.Syntax in - let+ status = take_while1 is_digit in - Http.Status.of_int (int_of_string status) - -let reason_phrase = - Buf_read.take_while (function - | '\x21' .. '\x7E' | '\t' | ' ' -> true - | _ -> false) - -(* https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 *) -let response buf_read = - let open Buf_read.Syntax in - let version = Rwer.(version <* space) buf_read in - let status = Rwer.(status_code <* space) buf_read in - let () = Rwer.(reason_phrase *> crlf *> Buf_read.return ()) buf_read in - let headers = Rwer.http_headers buf_read in - Http.Response.make ~version ~status ~headers () - -(* Generic HTTP call *) - -let call ?(pipeline_requests = false) ?meth ?version - ?(headers = Http.Header.init ()) ?(body = Body.Empty) ?conn ?port env ~host - resource_path = - let headers = - if not (Http.Header.mem headers "Host") then - let host = - match port with - | Some port -> host ^ ":" ^ string_of_int port - | None -> host - in - Http.Header.add headers "Host" host - else headers - in - let headers = - Http.Header.add_unless_exists headers "User-Agent" "cohttp-eio" - in - let buf_write conn = - let initial_size = 0x1000 in - Buf_write.with_flow ~initial_size:0x1000 conn (fun writer -> - let request = Http.Request.make ?meth ?version ~headers resource_path in - let request = Http.Request.add_te_trailers request in - write_request pipeline_requests request writer body; - let reader = - Eio.Buf_read.of_flow ~initial_size ~max_size:max_int conn + let call = + map_context call (fun call ?headers ?body meth uri -> + let () = + Logs.info (fun m -> m "%a %a" Http.Method.pp meth Uri.pp uri) in - let response = response reader in - (response, reader)) - in - match conn with - | None -> - let service = - match port with Some p -> string_of_int p | None -> "80" - in - Eio.Net.with_tcp_connect ~host ~service env#net (fun conn -> - buf_write conn) - | Some conn -> buf_write conn - -(* HTTP Calls with Body Disallowed *) -let call_without_body ?pipeline_requests ?meth ?version ?headers ?conn ?port env - ~host resource_path = - call ?pipeline_requests ?meth ?version ?headers ?conn ?port env ~host - resource_path - -let get = call_without_body ~meth:`GET -let head = call_without_body ~meth:`HEAD -let delete = call_without_body ~meth:`DELETE - -(* HTTP Calls with Body Allowed *) - -let post = call ~meth:`POST -let put = call ~meth:`PUT -let patch = call ~meth:`PATCH - -(* Response Body *) - -let read_fixed ((response, reader) : Http.Response.t * Buf_read.t) = - match Http.Response.content_length response with - | Some content_length -> Buf_read.take content_length reader - | None -> Buf_read.take_all reader - -let read_chunked : response -> (Body.chunk -> unit) -> Http.Header.t option = - fun (response, reader) f -> Body.read_chunked reader response.headers f + call ?headers ?body meth uri) + + let delete = + map_context call (fun call ?headers uri -> call ?headers `DELETE uri) + + let get = map_context call (fun call ?headers uri -> call ?headers `GET uri) + let head = map_context call (fun call ?headers uri -> call ?headers `HEAD uri) + + let patch = + map_context call (fun call ?headers uri -> call ?headers `PATCH uri) + + let post = map_context call (fun call ?headers uri -> call ?headers `POST uri) + let put = map_context call (fun call ?headers uri -> call ?headers `PUT uri) +end + +include Make (struct + type 'a with_context = Eio.Net.t -> sw:Eio.Switch.t -> 'a + + let map_context v f net ~sw = f (v net ~sw) + + let call net ~sw ?headers ?body meth uri = + let ( let* ) = Result.bind in + let* addr = + match Uri.scheme uri with + | Some "httpunix" + (* FIXME: while there is no standard, http+unix seems more widespread *) + -> ( + match Uri.host uri with + | Some path -> Result.Ok (`Unix path) + | None -> Result.Error "no host specified with httpunix") + | _ -> ( + let service = + match Uri.port uri with + | Some port -> Int.to_string port + | _ -> Uri.scheme uri |> Option.value ~default:"http" + in + match + Eio.Net.getaddrinfo_stream ~service net + (Uri.host_with_default ~default:"localhost" uri) + with + | ip :: _ -> Result.Ok ip + | [] -> Result.Error "failed to resolve hostname") + in + let socket = Eio.Net.connect ~sw net addr in + let body_length = + match body with + | None -> Some 0L + | Some body -> + List.find_map + (function + | Body.String s -> Some (String.length s |> Int64.of_int) + | _ -> None) + (Eio.Flow.read_methods body) + in + let request = + Cohttp.Request.make_for_client ?headers + ~chunked:(Option.is_none body_length) + ?body_length meth uri + in + Eio.Buf_write.with_flow socket @@ fun output -> + let () = + Eio.Fiber.fork ~sw @@ fun () -> + Io.Request.write + (fun writer -> + match body with + | None -> () + | Some body -> flow_to_writer body writer Io.Request.write_body) + request output + in + let input = Eio.Buf_read.of_flow ~max_size:max_int socket in + match Io.Response.read input with + | `Eof -> Result.Error "connection closed by peer" + | `Invalid reason -> Result.Error reason + | `Ok response -> ( + match Cohttp.Response.has_body response with + | `No -> Result.Ok (response, Eio.Flow.string_source "") + | `Yes | `Unknown -> + let body = + let reader = Io.Response.make_body_reader response input in + flow_of_reader reader Io.Response.read_body_chunk + in + Result.Ok (response, body)) + + module Io = Io.IO +end) diff --git a/cohttp-eio/src/client.mli b/cohttp-eio/src/client.mli new file mode 100644 index 0000000000..962effc2f2 --- /dev/null +++ b/cohttp-eio/src/client.mli @@ -0,0 +1,6 @@ +include module type of Client_intf + +include + S + with type 'a with_context = Eio.Net.t -> sw:Eio.Switch.t -> 'a + and module Io = Io.IO diff --git a/cohttp-eio/src/client_intf.ml b/cohttp-eio/src/client_intf.ml new file mode 100644 index 0000000000..e0184b17f1 --- /dev/null +++ b/cohttp-eio/src/client_intf.ml @@ -0,0 +1,63 @@ +module type BASE = sig + module Io : Cohttp.S.IO + + type 'a with_context + + val map_context : 'a with_context -> ('a -> 'b) -> 'b with_context + + val call : + (?headers:Http.Header.t -> + ?body:Eio.Flow.source -> + Http.Method.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP request with arbitrary method and a body. If the URI has a + host, we use a TCP connection, otherwaise a UNIX domain socket. *) +end + +module type S = sig + include BASE + + val delete : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP DELETE request *) + + val get : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP GET request *) + + val head : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP HEAD request *) + + val patch : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP PATCH request *) + + val post : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP POST request *) + + val put : + (?headers:Http.Header.t -> + Uri.t -> + (Http.Response.t * Eio.Flow.source, string) Result.t Io.t) + with_context + (** Send an HTTP PUT request *) +end diff --git a/cohttp-eio/src/cohttp_eio.ml b/cohttp-eio/src/cohttp_eio.ml index 284353ff92..4fa1811039 100644 --- a/cohttp-eio/src/cohttp_eio.ml +++ b/cohttp-eio/src/cohttp_eio.ml @@ -1,3 +1,5 @@ module Body = Body -module Server = Server module Client = Client +module Server = Server + +let src = Utils.src diff --git a/cohttp-eio/src/cohttp_eio.mli b/cohttp-eio/src/cohttp_eio.mli deleted file mode 100644 index 307cb10993..0000000000 --- a/cohttp-eio/src/cohttp_eio.mli +++ /dev/null @@ -1,222 +0,0 @@ -module Body : sig - type t = - | Fixed of string - | Chunked of chunk_writer - | Custom of (Eio.Buf_write.t -> unit) - | Empty - - and chunk_writer = { - body_writer : (chunk -> unit) -> unit; - trailer_writer : (Http.Header.t -> unit) -> unit; - } - - (** [Chunk] encapsulates HTTP/1.1 chunk transfer encoding data structures. - https://datatracker.ietf.org/doc/html/rfc7230#section-4.1 *) - and chunk = Chunk of chunk_body | Last_chunk of chunk_extension list - - and chunk_body = { - size : int; - data : string; - extensions : chunk_extension list; - } - - and chunk_extension = { name : string; value : string option } - - val pp_chunk_extension : Format.formatter -> chunk_extension list -> unit - val pp_chunk : Format.formatter -> chunk -> unit -end - -(** [Server] is a HTTP 1.1 server. *) -module Server : sig - type request = Http.Request.t * Eio.Buf_read.t * Eio.Net.Sockaddr.stream - (** The request headers, a reader for the socket, and the address of the - client. To read the request body, use {!read_fixed} or {!read_chunked}. *) - - type response = Http.Response.t * Body.t - type handler = request -> response - - type 'a env = - < domain_mgr : Eio.Domain_manager.t - ; net : Eio.Net.t - ; clock : Eio.Time.clock - ; .. > - as - 'a - - (** {1 Request Body} *) - - val read_fixed : Http.Request.t -> Eio.Buf_read.t -> string option - (** [read_fixed (request, buf_read)] is [Some content], where [content] is of - length [n] if "Content-Length" header is a valid integer value [n] in - [request]. - - [buf_read] is updated to reflect that [n] bytes was read. - - If ["Content-Length"] header is missing or is an invalid value in - [request] OR if the request http method is not one of [POST], [PUT] or - [PATCH], then [None] is returned. *) - - val read_chunked : - Http.Request.t -> - Eio.Buf_read.t -> - (Body.chunk -> unit) -> - Http.Header.t option - (** [read_chunked request buf_read chunk_handler] is [Some updated_headers] if - "Transfer-Encoding" header value is "chunked" in [request] and all chunks - in [buf_read] are read successfully. [updated_headers] is the updated - headers as specified by the chunked encoding algorithm in https: - //datatracker.ietf.org/doc/html/rfc7230#section-4.1.3. - - [buf_read] is updated to reflect the number of bytes read. Returns [None] - if [Transfer-Encoding] header in [headers] is not specified as "chunked" *) - - (** {1 Response} *) - - val text_response : string -> response - (** [text t s] returns a HTTP/1.1, 200 status response with "Content-Type" - header set to "text/plain". *) - - val html_response : string -> response - (** [html t s] returns a HTTP/1.1, 200 status response with header set to - "Content-Type: text/html". *) - - val not_found_response : response - (** [not_found t] returns a HTTP/1.1, 404 status response. *) - - val internal_server_error_response : response - (** [internal_server_error] returns a HTTP/1.1, 500 status response. *) - - val bad_request_response : response - (* [bad_request t] returns a HTTP/1.1, 400 status response. *) - - (** {1 Run Server} *) - - val run : - ?socket_backlog:int -> ?domains:int -> port:int -> 'a env -> handler -> 'b - (** [run ~socket_backlog ~domains ~port env handler] runs a HTTP/1.1 server - executing [handler] and listening on [port]. [env] corresponds to - {!val:Eio.Stdenv.t}. - - [socket_backlog] is the number of pending connections for tcp server - socket. The default is [128]. - - [domains] is the number of OCaml 5.0 domains the server will use. The - default is [1]. You may use {!val:Domain.recommended_domain_count} to - configure a multicore capable server. *) - - val connection_handler : - handler -> - #Eio.Time.clock -> - #Eio.Net.stream_socket -> - Eio.Net.Sockaddr.stream -> - unit - (** [connection_handler request_handler] is a connection handler, suitable for - passing to {!Eio.Net.accept_fork}. *) - - (** {1 Basic Handlers} *) - - val not_found_handler : handler - (** [not_found_handler] return HTTP 404 response. *) -end - -(** [Client] is a HTTP/1.1 client. *) -module Client : sig - type response = Http.Response.t * Eio.Buf_read.t - - type host = string - (** Represents a server host - as ip address or domain name, e.g. - www.example.org:8080, www.reddit.com*) - - type port = int - (** Represents a tcp/ip port value *) - - type resource_path = string - (** Represents HTTP request resource path, e.g. "/shop/purchase", - "/shop/items", "/shop/categories/" etc. *) - - type 'a env = < net : Eio.Net.t ; .. > as 'a - - type ('a, 'b) body_disallowed_call = - ?pipeline_requests:bool -> - ?version:Http.Version.t -> - ?headers:Http.Header.t -> - ?conn:(#Eio.Flow.two_way as 'a) -> - ?port:port -> - 'b env -> - host:host -> - resource_path -> - response - (** [body_disallowed_call] denotes HTTP client calls where a request is not - allowed to have a request body. - - @param pipeline_requests - If [true] then attempts to batch multiple client requests to improve - request/reponse throughput. Set this to [false] if you want to improve - latency of individual client request/response. Default is [false]. *) - - type ('a, 'b) body_allowed_call = - ?pipeline_requests:bool -> - ?version:Http.Version.t -> - ?headers:Http.Header.t -> - ?body:Body.t -> - ?conn:(#Eio.Flow.two_way as 'a) -> - ?port:port -> - 'b env -> - host:host -> - resource_path -> - response - (** [body_allowed_call] denotes HTTP client calls where a request can - optionally have a request body. - - @param pipeline_requests - If [true] then attempts to batch multiple client requests to improve - request/reponse throughput. Set this to [false] if you want to improve - latency of individual client request/response. Default is [false]. *) - - (** {1 Generic HTTP call} *) - - val call : - ?pipeline_requests:bool -> - ?meth:Http.Method.t -> - ?version:Http.Version.t -> - ?headers:Http.Header.t -> - ?body:Body.t -> - ?conn:#Eio.Flow.two_way -> - ?port:port -> - 'a env -> - host:host -> - resource_path -> - response - - (** {1 HTTP Calls with Body Disallowed} *) - - val get : ('a, 'b) body_disallowed_call - val head : ('a, 'b) body_disallowed_call - val delete : ('a, 'b) body_disallowed_call - - (** {1 HTTP Calls with Body Allowed} *) - - val post : ('a, 'b) body_allowed_call - val put : ('a, 'b) body_allowed_call - val patch : ('a, 'b) body_allowed_call - - (** {1 Response Body} *) - - val read_fixed : response -> string - (** [read_fixed (response,reader)] is [body_content], where [body_content] is - of length [n] if "Content-Length" header exists and is a valid integer - value [n] in [response]. Otherwise [body_content] holds all bytes until - eof. *) - - val read_chunked : response -> (Body.chunk -> unit) -> Http.Header.t option - (** [read_chunked response chunk_handler] is [Some updated_headers] if - "Transfer-Encoding" header value is "chunked" in [response] and all chunks - in [reader] are read successfully. [updated_headers] is the updated - headers as specified by the chunked encoding algorithm in https: - //datatracker.ietf.org/doc/html/rfc7230#section-4.1.3. - - [reader] is updated to reflect the number of bytes read. - - Returns [None] if [Transfer-Encoding] header in [headers] is not specified - as "chunked" *) -end diff --git a/cohttp-eio/src/dune b/cohttp-eio/src/dune index 01598f9bb9..c54071b74a 100644 --- a/cohttp-eio/src/dune +++ b/cohttp-eio/src/dune @@ -1,4 +1,4 @@ (library (name cohttp_eio) (public_name cohttp-eio) - (libraries eio http fmt ptime)) + (libraries cohttp eio eio.mock fmt http logs ptime uri uri.services)) diff --git a/cohttp-eio/src/io.ml b/cohttp-eio/src/io.ml new file mode 100644 index 0000000000..3ffb500b9f --- /dev/null +++ b/cohttp-eio/src/io.ml @@ -0,0 +1,52 @@ +module IO = struct + type 'a t = 'a + + let ( >>= ) v f = f v + let return v = v + + type ic = Eio.Buf_read.t + type oc = Eio.Buf_write.t + type conn = unit + + let refill ic = + try + let () = Eio.Buf_read.(ensure ic (buffered_bytes ic + 1)) in + `Ok + with End_of_file -> `Eof + + let with_input_buffer ic ~f = + let contents = Eio.Buf_read.peek ic in + let res, consumed = + f (Cstruct.to_string contents) ~pos:0 ~len:(Cstruct.length contents) + in + let () = Eio.Buf_read.consume ic consumed in + res + + let read_line ic = + let line = Eio.Buf_read.take_while (fun c -> not (Char.equal c '\r')) ic in + match Eio.Buf_read.any_char ic with + | exception End_of_file -> None + | _ -> + let () = + match Eio.Buf_read.peek_char ic with + | Some '\n' -> Eio.Buf_read.consume ic 1 + | _ -> () + in + Some line + + let rec read ic len = + let contents = Eio.Buf_read.peek ic in + if Cstruct.length contents = 0 then + match refill ic with `Eof -> "" | `Ok -> read ic len + else + let consumed = Int.min len (Cstruct.length contents) in + let () = Eio.Buf_read.consume ic consumed in + Cstruct.to_string ~len:consumed contents + + let write oc string = Eio.Buf_write.string oc string + let flush = Eio.Buf_write.flush +end + +module Request = Cohttp.Request.Private.Make (IO) +module Response = Cohttp.Response.Private.Make (IO) +module Transfer = Cohttp.Private.Transfer_io.Make (IO) diff --git a/cohttp-eio/src/io.mli b/cohttp-eio/src/io.mli new file mode 100644 index 0000000000..185ee8e629 --- /dev/null +++ b/cohttp-eio/src/io.mli @@ -0,0 +1,13 @@ +module IO : + Cohttp.S.IO + with type 'a t = 'a + and type ic = Eio.Buf_read.t + and type oc = Eio.Buf_write.t + +module Request : + Cohttp.S.Http_io with type t := Http.Request.t and module IO := IO + +module Response : + Cohttp.S.Http_io with type t := Http.Response.t and module IO := IO + +(* module Transfer : module type of Cohttp.Private.Transfer_io.Make (IO) *) diff --git a/cohttp-eio/src/rwer.ml b/cohttp-eio/src/rwer.ml deleted file mode 100644 index ac3d56ddc5..0000000000 --- a/cohttp-eio/src/rwer.ml +++ /dev/null @@ -1,62 +0,0 @@ -(* This modules encapsulates refactored - common - readers and writers - used by the Client and Server modules. - - rwer.ml => (R)eader (W)riter + er -*) - -module Buf_read = Eio.Buf_read -module Buf_write = Eio.Buf_write - -let take_while1 p r = - match Buf_read.take_while p r with "" -> raise End_of_file | x -> x - -let token = - take_while1 (function - | '0' .. '9' - | 'a' .. 'z' - | 'A' .. 'Z' - | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' - | '`' | '|' | '~' -> - true - | _ -> false) - -let ows = Buf_read.skip_while (function ' ' | '\t' -> true | _ -> false) -let crlf = Buf_read.string "\r\n" -let not_cr = function '\r' -> false | _ -> true -let space = Buf_read.char '\x20' - -let version = - let open Eio.Buf_read.Syntax in - let* v = Buf_read.string "HTTP/1." *> Buf_read.any_char in - match v with - | '1' -> Buf_read.return `HTTP_1_1 - | '0' -> Buf_read.return `HTTP_1_0 - | v -> failwith (Format.sprintf "Invalid HTTP version: %C" v) - -let header = - let open Eio.Buf_read.Syntax in - let+ key = token <* Buf_read.char ':' <* ows - and+ value = Buf_read.take_while not_cr <* crlf in - (key, value) - -let http_headers r = - let[@tail_mod_cons] rec aux () = - match Buf_read.peek_char r with - | Some '\r' -> - crlf r; - [] - | _ -> - let h = header r in - h :: aux () - in - Http.Header.of_list (aux ()) - -let write_headers writer headers = - let headers = Http.Header.clean_dup headers in - Http.Header.iter - (fun k v -> - Buf_write.string writer k; - Buf_write.string writer ": "; - Buf_write.string writer v; - Buf_write.string writer "\r\n") - headers diff --git a/cohttp-eio/src/server.ml b/cohttp-eio/src/server.ml index 74fe90189f..07f949dcc7 100644 --- a/cohttp-eio/src/server.ml +++ b/cohttp-eio/src/server.ml @@ -1,205 +1,91 @@ -module Buf_read = Eio.Buf_read -module Buf_write = Eio.Buf_write -module Switch = Eio.Switch - -type middleware = handler -> handler -and handler = request -> response -and request = Http.Request.t * Eio.Buf_read.t * Eio.Net.Sockaddr.stream -and response = Http.Response.t * Body.t - -type 'a env = - < domain_mgr : Eio.Domain_manager.t - ; net : Eio.Net.t - ; clock : Eio.Time.clock - ; .. > - as - 'a - -(* Request *) - -let read_fixed request reader = - match Http.Request.meth request with - | `POST | `PUT | `PATCH -> - let ( let* ) o f = Option.bind o f in - let ( let+ ) o f = Option.map f o in - let* v = Http.Header.get request.headers "Content-Length" in - let+ content_length = int_of_string_opt v in - Buf_read.take content_length reader - | _ -> None - -let read_chunked request reader f = - Body.read_chunked reader (Http.Request.headers request) f - -(* Responses *) - -let is_custom body = match body with Body.Custom _ -> true | _ -> false - -let text_response body = - let headers = - Http.Header.of_list - [ - ("content-type", "text/plain; charset=UTF-8"); - ("content-length", string_of_int @@ String.length body); - ] +open Utils + +type handler = + Http.Request.t -> Eio.Flow.source -> Http.Response.t * Eio.Flow.source + +type t = { + addr : Eio.Net.Sockaddr.t; + handler : handler; + socket : Eio.Net.listening_socket; +} + +let make net ~sw ?(backlog = 128) ?(host = Eio.Net.Ipaddr.V4.loopback) + ?(port = 0) handler = + let socket = + Eio.Net.listen net ~sw ~reuse_addr:true ~reuse_port:true ~backlog + (`Tcp (host, port)) in - let response = - Http.Response.make ~version:`HTTP_1_1 ~status:`OK ~headers () + let addr = + (* FIXME: this can be incorrect, fix when + https://github.com/ocaml-multicore/eio/pull/555 is merged *) + `Tcp (host, port) in - (response, Body.Fixed body) + { addr; handler; socket } + +let listening_addr { addr; _ } = addr + +let read _peer_address socket = + let input = Eio.Buf_read.of_flow ~max_size:max_int socket in + match Io.Request.read input with + | (`Eof | `Invalid _) as e -> e + | `Ok request -> + let body = + let reader = Io.Request.make_body_reader request input in + flow_of_reader reader Io.Request.read_body_chunk + in + `Ok (request, body) -let html_response body = - let headers = - Http.Header.of_list - [ - ("content-type", "text/html; charset=UTF-8"); - ("content-length", string_of_int @@ String.length body); - ] - in +let write _peer_address socket response body = let response = - Http.Response.make ~version:`HTTP_1_1 ~status:`OK ~headers () - in - (response, Body.Fixed body) - -let not_found_response = (Http.Response.make ~status:`Not_found (), Body.Empty) - -let internal_server_error_response = - (Http.Response.make ~status:`Internal_server_error (), Body.Empty) - -let bad_request_response = - (Http.Response.make ~status:`Bad_request (), Body.Empty) - -let http_date clock = - let now = Eio.Time.now clock |> Ptime.of_float_s |> Option.get in - let (year, mm, dd), ((hh, min, ss), _) = Ptime.to_date_time now in - let weekday = Ptime.weekday now in - let weekday = - match weekday with - | `Mon -> "Mon" - | `Tue -> "Tue" - | `Wed -> "Wed" - | `Thu -> "Thu" - | `Fri -> "Fri" - | `Sat -> "Sat" - | `Sun -> "Sun" + let content_length = + List.find_map + (function Body.String s -> Some (String.length s) | _ -> None) + (Eio.Flow.read_methods body) + in + (* encoding field might be deprecated but it is still used + to compute headers and encode the body*) + match content_length with + | None -> + { response with Cohttp.Response.encoding = Chunked } + [@ocaml.warning "-3"] + | Some size -> + { response with encoding = Fixed (Int64.of_int size) } + [@ocaml.warning "-3"] in - let month = - match mm with - | 1 -> "Jan" - | 2 -> "Feb" - | 3 -> "Mar" - | 4 -> "Apr" - | 5 -> "May" - | 6 -> "Jun" - | 7 -> "Jul" - | 8 -> "Aug" - | 9 -> "Sep" - | 10 -> "Oct" - | 11 -> "Nov" - | 12 -> "Dec" - | _ -> failwith "Invalid HTTP datetime value" + let () = + Eio.Buf_write.with_flow socket @@ fun output -> + Io.Response.write + (fun writer -> flow_to_writer body writer Io.Response.write_body) + response output in - Format.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" weekday dd month year hh - min ss - -let write_response ?request clock writer (response, body) = - let headers = - let request_meth = Option.map Http.Request.meth request in - Body.add_content_length - (Http.Response.requires_content_length ?request_meth response) - (Http.Response.headers response) - body - in - let headers = - (* https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1 *) - match Http.Response.status response with - | #Http.Status.informational | #Http.Status.server_error -> headers - | _ -> Http.Header.add headers "Date" (http_date clock) - in - let version = Http.Version.to_string response.version in - let status = Http.Status.to_string response.status in - Buf_write.string writer version; - Buf_write.char writer ' '; - Buf_write.string writer status; - Buf_write.string writer "\r\n"; - Rwer.write_headers writer headers; - Buf_write.string writer "\r\n"; - let write_chunked_trailers = - Option.map Http.Request.supports_chunked_trailers request - in - Body.write_body ?write_chunked_trailers writer body - -(* request parsers *) - -let meth = - let open Eio.Buf_read.Syntax in - let+ meth = Rwer.(token <* space) in - Http.Method.of_string meth - -let resource = - let open Eio.Buf_read.Syntax in - Rwer.(take_while1 (fun c -> c != ' ') <* space) - -let[@warning "-3"] http_request t = - let open Eio.Buf_read.Syntax in - let meth = meth t in - let resource = resource t in - let version = Rwer.(version <* crlf) t in - let headers = Rwer.http_headers t in - let encoding = Http.Header.get_transfer_encoding headers in - { Http.Request.meth; resource; version; headers; scheme = None; encoding } - -(* main *) - -let rec handle_request clock client_addr reader writer flow handler = - match http_request reader with - | request -> - let response, body = handler (request, reader, client_addr) in - write_response ~request clock writer (response, body); - if Http.Request.is_keep_alive request then - handle_request clock client_addr reader writer flow handler - | (exception End_of_file) - | (exception Eio.Io (Eio.Net.E (Connection_reset _), _)) -> - () - | exception (Failure _ as ex) -> - write_response clock writer bad_request_response; - raise ex - | exception ex -> - write_response clock writer internal_server_error_response; - raise ex - -let connection_handler (handler : handler) clock flow client_addr = - let reader = Buf_read.of_flow ~initial_size:0x1000 ~max_size:max_int flow in - Buf_write.with_flow flow (fun writer -> - handle_request clock client_addr reader writer flow handler) - -let run_domain env ssock handler = - let on_error exn = - Printf.fprintf stderr "Error handling connection: %s\n%!" - (Printexc.to_string exn) - in - let handler = connection_handler handler env#clock in - Switch.run (fun sw -> - let rec loop () = - Eio.Net.accept_fork ~sw ssock ~on_error handler; - loop () + `Ok + +let handle (handler : handler) peer_address socket = + match read peer_address socket with + | (`Eof | `Invalid _) as e -> e + | `Ok (request, body) -> + let response, body = handler request body in + write peer_address socket response body + +let run { handler; socket; _ } = + let rec accept () = + let () = + Eio.Switch.run @@ fun sw -> + let socket, peer_address = Eio.Net.accept ~sw socket in + let () = + Logs.info (fun m -> + m "%a: accept connection" Eio.Net.Sockaddr.pp peer_address) in - loop ()) - -let run ?(socket_backlog = 128) ?(domains = 1) ~port env handler = - Switch.run @@ fun sw -> - let domain_mgr = Eio.Stdenv.domain_mgr env in - let ssock = - Eio.Net.listen (Eio.Stdenv.net env) ~sw ~reuse_addr:true ~reuse_port:true - ~backlog:socket_backlog - (`Tcp (Eio.Net.Ipaddr.V4.loopback, port)) + Eio.Fiber.fork ~sw @@ fun () -> + match handle handler peer_address socket with + | `Eof -> + Logs.info (fun m -> + m "%a: connection closed" Eio.Net.Sockaddr.pp peer_address) + | `Invalid error -> + Logs.warn (fun m -> + m "%a: invalid request: %s" Eio.Net.Sockaddr.pp peer_address error) + | `Ok -> () + in + accept () in - for _ = 2 to domains do - Eio.Std.Fiber.fork ~sw (fun () -> - Eio.Domain_manager.run domain_mgr (fun () -> - run_domain env ssock handler)) - done; - run_domain env ssock handler - -(* Basic handlers *) - -let not_found_handler _ = not_found_response + accept () diff --git a/cohttp-eio/src/utils.ml b/cohttp-eio/src/utils.ml new file mode 100644 index 0000000000..868ce0d48f --- /dev/null +++ b/cohttp-eio/src/utils.ml @@ -0,0 +1,48 @@ +let src = Logs.Src.create "cohttp.eio" ~doc:"Cohttp EIO backend" + +module Logs = (val Logs.src_log src) + +let flow_of_reader reader read_body_chunk = + object + inherit Eio.Flow.source + val mutable buffered = None + + method read_into output = + let output_length = Cstruct.length output in + let send buffer pos = + let available = String.length buffer - pos in + if output_length >= available then + let () = Cstruct.blit_from_string buffer pos output 0 available + and () = buffered <- None in + available + else + let () = Cstruct.blit_from_string buffer 0 output 0 output_length + and () = buffered <- Some (buffer, pos + output_length) in + output_length + in + match buffered with + | Some (buffer, pos) -> send buffer pos + | None -> ( + match read_body_chunk reader with + | Cohttp.Transfer.Done -> + let () = Logs.debug (fun m -> m "end of body") in + raise End_of_file + | Chunk data | Final_chunk data -> + let () = + Logs.debug (fun m -> + m "received %d bytes of body" (String.length data)) + in + send data 0) + end + +let flow_to_writer flow writer write_body = + let input = Eio.Buf_read.of_flow ~max_size:max_int flow in + let rec loop () = + let () = + let () = Eio.Buf_read.ensure input 1 in + let contents = Eio.Buf_read.(take (buffered_bytes input) input) in + write_body writer contents + in + loop () + in + try loop () with End_of_file -> () diff --git a/cohttp-eio/tests/chunks.txt b/cohttp-eio/tests/chunks.txt deleted file mode 100644 index 94edd3e041..0000000000 --- a/cohttp-eio/tests/chunks.txt +++ /dev/null @@ -1,3 +0,0 @@ -Mozilla -Developer -Network diff --git a/cohttp-eio/tests/client.md b/cohttp-eio/tests/client.md deleted file mode 100644 index 98fcd16fbe..0000000000 --- a/cohttp-eio/tests/client.md +++ /dev/null @@ -1,251 +0,0 @@ -## Setup - -```ocaml -open Eio.Std -open Cohttp_eio -``` - -A mock client socket and host for testing: - -```ocaml -let host = "localhost" -let conn = Eio_mock.Flow.make "socket" -let mock_env = - object - method net = (Eio_mock.Net.make "mock net" :> Eio.Net.t) - end - -let run ~response ~test = - Eio_mock.Backend.run @@ fun () -> - Fiber.both - (fun () -> Eio_mock.Flow.on_read conn response) - test -``` - -## Tests - -GET method request: - -```ocaml -# run ~response: - [`Return "HTTP/1.1 200 OK\r\n"; - `Return "content-length: 4\r\n"; - `Return "content-type: text/plain; charset=UTF-8\r\n\r\n"; - `Return "root"; - `Raise End_of_file - ] - ~test:(fun () -> - (Client.get - ~headers:(Http.Header.of_list [ ("Accept", "application/json") ]) - ~conn - ~host - mock_env - "/") - |> Client.read_fixed - |> print_string);; -+socket: wrote "GET / HTTP/1.1\r\n" -+ "Host: localhost\r\n" -+ "Connection: TE\r\n" -+ "TE: trailers\r\n" -+ "User-Agent: cohttp-eio\r\n" -+ "Accept: application/json\r\n" -+ "\r\n" -+socket: read "HTTP/1.1 200 OK\r\n" -+socket: read "content-length: 4\r\n" -+socket: read "content-type: text/plain; charset=UTF-8\r\n" -+ "\r\n" -+socket: read "root" -root -- : unit = () -``` - -POST request: - -```ocaml -# run ~response: - [`Return "HTTP/1.1 200 OK\r\n"; - `Return "content-length: 5\r\n\r\n"; - `Return "hello"; - `Raise End_of_file - ] - ~test:(fun () -> - let content = "hello world!" in - let content_length = String.length content |> string_of_int in - Client.post - ~headers: - (Http.Header.of_list [("Accept", "application/json"); ("Content-Length", content_length);]) - ~body:(Body.Fixed content) - ~conn - ~host - mock_env - "/post" - |> Client.read_fixed - |> print_string);; -+socket: wrote "POST /post HTTP/1.1\r\n" -+ "Host: localhost\r\n" -+ "Connection: TE\r\n" -+ "TE: trailers\r\n" -+ "User-Agent: cohttp-eio\r\n" -+ "Content-Length: 12\r\n" -+ "Accept: application/json\r\n" -+ "\r\n" -+ "hello world!" -+socket: read "HTTP/1.1 200 OK\r\n" -+socket: read "content-length: 5\r\n" -+ "\r\n" -+socket: read "hello" -hello -- : unit = () -``` - -Chunk request: - -```ocaml -# run ~response: - [`Return "HTTP/1.1 200 OK\r\n"; - `Return "content-length:0\r\n\r\n"; - `Raise End_of_file; - ] - ~test:(fun () -> - let rec body_writer chan chunks f = - match In_channel.input_line chan with - | Some data -> - let extensions = - if chunks = 0 then - [ - Body.{ name = "ext1"; value = Some "ext1_v" }; - { name = "ext2"; value = Some "ext2_v" }; - { name = "ext3"; value = None }; - ] - else [] - in - let chunk = - Body.Chunk { size = String.length data; data; extensions } - in - f chunk; - body_writer chan (chunks + 1) f - | None -> - let last_chunk = Body.Last_chunk [] in - f last_chunk - in - let trailer_writer f = - let trailer_headers = - Http.Header.of_list - [ - ("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); - ("Header1", "Header1 value text"); - ("Header2", "Header2 value text"); - ] - in - f trailer_headers - in - In_channel.with_open_text "chunks.txt" (fun chan -> - Client.post - ~headers: - (Http.Header.of_list - [ - ("Transfer-Encoding", "chunked"); - ("Content-Type", "text/plain"); - ("Trailer", "Expires, Header1"); - ]) - ~body: - (Body.Chunked { body_writer = body_writer chan 0; trailer_writer }) - ~conn - ~host - mock_env - "/handle_chunk" - ) - |> Client.read_fixed - |> print_string);; -+socket: wrote "POST /handle_chunk HTTP/1.1\r\n" -+ "Host: localhost\r\n" -+ "Connection: TE\r\n" -+ "TE: trailers\r\n" -+ "User-Agent: cohttp-eio\r\n" -+ "Trailer: Expires, Header1\r\n" -+ "Content-Type: text/plain\r\n" -+ "Transfer-Encoding: chunked\r\n" -+ "\r\n" -+ "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n" -+ "Mozilla\r\n" -+ "9\r\n" -+ "Developer\r\n" -+ "7\r\n" -+ "Network\r\n" -+ "0\r\n" -+ "Header2: Header2 value text\r\n" -+ "Header1: Header1 value text\r\n" -+ "Expires: Wed, 21 Oct 2015 07:28:00 GMT\r\n" -+ "\r\n" -+socket: read "HTTP/1.1 200 OK\r\n" -+socket: read "content-length:0\r\n" -+ "\r\n" -- : unit = () -``` - -```ocaml -# run ~response: - [`Return "HTTP/1.1 200 OK\r\n"; - `Return "Trailer: Expires, Header1\r\n"; - `Return "Content-Type: text/plain\r\n"; - `Return "Transfer-Encoding: chunked\r\n"; - `Return "\r\n"; - `Return "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n"; - `Return "Mozilla\r\n"; - `Return "9\r\n"; - `Return "Developer\r\n"; - `Return "7\r\n"; - `Return "Network\r\n"; - `Return "0\r\n"; - `Return "\r\n"; - `Raise End_of_file - ] - ~test:(fun () -> - let print_chunk chunk = traceln "chunk body: %a\n" Body.pp_chunk chunk in - let res = Client.get ~conn ~host mock_env "/get_chunk" in - match Client.read_chunked res print_chunk with - | None -> print_string "FAIL" - | Some _ -> print_string "PASS" - );; -+socket: wrote "GET /get_chunk HTTP/1.1\r\n" -+ "Host: localhost\r\n" -+ "Connection: TE\r\n" -+ "TE: trailers\r\n" -+ "User-Agent: cohttp-eio\r\n" -+ "\r\n" -+socket: read "HTTP/1.1 200 OK\r\n" -+socket: read "Trailer: Expires, Header1\r\n" -+socket: read "Content-Type: text/plain\r\n" -+socket: read "Transfer-Encoding: chunked\r\n" -+socket: read "\r\n" -+socket: read "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n" -+socket: read "Mozilla\r\n" -+chunk body: size: 7 -+ data: Mozilla -+ extensions: -+ name: ext1 -+ value: ext1_v; -+ name: ext2 -+ value: ext2_v; -+ name: ext3 -+ value: -+ -+socket: read "9\r\n" -+socket: read "Developer\r\n" -+chunk body: size: 9 -+ data: Developer -+ extensions: -+ -+socket: read "7\r\n" -+socket: read "Network\r\n" -+chunk body: size: 7 -+ data: Network -+ extensions: -+ -+socket: read "0\r\n" -+socket: read "\r\n" -+chunk body: -+ -PASS -- : unit = () -``` diff --git a/cohttp-eio/tests/dune b/cohttp-eio/tests/dune index 3f9fe75e68..ba84e11ab8 100644 --- a/cohttp-eio/tests/dune +++ b/cohttp-eio/tests/dune @@ -1,4 +1,5 @@ -(mdx - (package cohttp-eio) - (deps server_chunks.txt chunks.txt) - (libraries eio eio.core eio.mock cohttp-eio http)) +(test + (name test) + (libraries alcotest eio eio_main cohttp-eio) + (preprocess + (pps ppx_here))) diff --git a/cohttp-eio/tests/server.md b/cohttp-eio/tests/server.md deleted file mode 100644 index 781b460065..0000000000 --- a/cohttp-eio/tests/server.md +++ /dev/null @@ -1,343 +0,0 @@ -## Setup - -```ocaml -open Eio.Std -open Cohttp_eio -``` - -A mock socket for testing: - -```ocaml -let socket = Eio_mock.Flow.make "socket" -``` - -## Example request handler - -```ocaml -let chunk data = Body.Chunk { size = String.length data; data; extensions = [] } -let end_chunks = Body.Last_chunk [] - -let stream_response () = - let headers = Http.Header.init () in - let headers = Http.Header.add_transfer_encoding headers Http.Transfer.Chunked in - let body_writer fn = fn (chunk "Hello"); Fiber.yield (); traceln "Resuming..."; fn (chunk "World"); fn end_chunks in - let trailer_writer _fn = () in - let body = Body.Chunked { body_writer; trailer_writer } in - Http.Response.make ~version:`HTTP_1_1 ~status:`OK ~headers (), body - -let post req body = - let body = Server.read_fixed req body |> Option.get in - let buf = Buffer.create 0 in - let fmt = Format.formatter_of_buffer buf in - Http.Request.pp fmt req; - Format.fprintf fmt "\n\n%s%!" body; - Server.text_response (Buffer.contents buf) - -let get_chunks () = - let rec body_writer chan chunks f = - match In_channel.input_line chan with - | Some data -> - let extensions = - if chunks = 0 then - [ - Body.{ name = "ext1"; value = Some "ext1_v" }; - { name = "ext2"; value = Some "ext2_v" }; - { name = "ext3"; value = None }; - ] - else [] - in - let chunk = - Body.Chunk { size = String.length data; data; extensions } - in - f chunk; - body_writer chan (chunks + 1) f - | None -> - let last_chunk = Body.Last_chunk [] in - In_channel.close chan; - f last_chunk - in - let trailer_writer f = - let trailer_headers = - Http.Header.of_list - [ - ("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); - ("Header1", "Header1 value text"); - ("Header2", "Header2 value text"); - ] - in - f trailer_headers - in - let chan = - In_channel.open_gen [ Open_text; Open_rdonly ] 0 "server_chunks.txt" - in - let headers = - Http.Header.of_list - [ - ("Transfer-Encoding", "chunked"); - ("Content-Type", "text/plain"); - ("Trailer", "Expires, Header1"); - ] - in - let response = Http.Response.make ~status:`OK ~headers () in - let body = - Body.Chunked { body_writer = body_writer chan 0; trailer_writer } - in - (response, body) - -let handle_chunk_request req body = - let dump_chunk buf chunk = - let s = Format.asprintf "\n%a" Body.pp_chunk chunk in - Buffer.add_string buf s - in - let chunk_buf = Buffer.create 0 in - match Server.read_chunked req body (dump_chunk chunk_buf) with - | Some headers -> - let req = { req with headers } in - Buffer.contents chunk_buf - |> Format.asprintf "%a@ %s%!" Http.Request.pp req - |> Server.text_response - | None -> Server.bad_request_response - -let app (req, body, _client_addr) = - match Http.Request.resource req with - | "/" -> Server.text_response "root" - | "/stream" -> stream_response () - | "/post" -> post req body - | "/get_chunks" -> get_chunks () - | "/handle_chunk" -> handle_chunk_request req body - | _ -> Server.not_found_response - -let mock_clock = Eio_mock.Clock.make () -let () = Eio_mock.Clock.set_time mock_clock 1666627935.85052109 - -let connection_handler = Server.connection_handler app mock_clock -``` - -To test it, we run the connection handler with our mock socket: - -```ocaml -let run test_case = - Eio_mock.Backend.run @@ fun () -> - Fiber.both test_case - (fun () -> - connection_handler socket (`Unix "test-socket") - );; -``` - -## Tests - -Asking for the root: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "GET / HTTP/1.1\r\n\r\n"; - `Raise End_of_file; - ];; -+socket: read "GET / HTTP/1.1\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "content-length: 4\r\n" -+ "content-type: text/plain; charset=UTF-8\r\n" -+ "\r\n" -+ "root" -- : unit = () -``` - -A missing page: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "GET /missing HTTP/1.1\r\n\r\n"; - `Raise End_of_file; - ] ;; -+socket: read "GET /missing HTTP/1.1\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 404 Not Found\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "Content-Length: 0\r\n" -+ "\r\n" -- : unit = () -``` - -Streaming a response: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "GET /stream HTTP/1.1\r\n\r\n"; - `Raise End_of_file; - ];; -+socket: read "GET /stream HTTP/1.1\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "transfer-encoding: chunked\r\n" -+ "\r\n" -+ "5\r\n" -+ "Hello\r\n" -+Resuming... -+socket: wrote "5\r\n" -+ "World\r\n" -+ "0\r\n" -+ "\r\n" -- : unit = () -``` - -Handle POST request: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "POST /post HTTP/1.1\r\n"; - `Return "Content-Length:12\r\n\r\n"; - `Return "hello world!"; - `Raise End_of_file; - ];; -+socket: read "POST /post HTTP/1.1\r\n" -+socket: read "Content-Length:12\r\n" -+ "\r\n" -+socket: read "hello world!" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "content-length: 100\r\n" -+ "content-type: text/plain; charset=UTF-8\r\n" -+ "\r\n" -+ "meth: POST\n" -+ "resource: /post\n" -+ "version: HTTP/1.1\n" -+ "headers: Header { Content-Length = \"12\" }\n" -+ "\n" -+ "hello world!" -- : unit = () -``` - -HTTP chunk-stream response with chunk extensions and trailers: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "GET /get_chunks HTTP/1.1\r\n"; - `Return "TE:trailers\r\n\r\n"; - `Raise End_of_file; - ];; -+socket: read "GET /get_chunks HTTP/1.1\r\n" -+socket: read "TE:trailers\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "Trailer: Expires, Header1\r\n" -+ "Content-Type: text/plain\r\n" -+ "Transfer-Encoding: chunked\r\n" -+ "\r\n" -+ "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n" -+ "Mozilla\r\n" -+ "9\r\n" -+ "Developer\r\n" -+ "7\r\n" -+ "Network\r\n" -+ "0\r\n" -+ "Header2: Header2 value text\r\n" -+ "Header1: Header1 value text\r\n" -+ "Expires: Wed, 21 Oct 2015 07:28:00 GMT\r\n" -+ "\r\n" -- : unit = () -``` - -The same request to `/get_chunks` will not write chunk headers because request is missing `TE: -trailers` header in the request. The `TE: trailers` is required for the server to determine if -a HTTP client agent has support for HTTP chunk trailer headers: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "GET /get_chunks HTTP/1.1\r\n\r\n"; - `Raise End_of_file; - ];; -+socket: read "GET /get_chunks HTTP/1.1\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "Trailer: Expires, Header1\r\n" -+ "Content-Type: text/plain\r\n" -+ "Transfer-Encoding: chunked\r\n" -+ "\r\n" -+ "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n" -+ "Mozilla\r\n" -+ "9\r\n" -+ "Developer\r\n" -+ "7\r\n" -+ "Network\r\n" -+ "0\r\n" -+ "\r\n" -- : unit = () -``` - -Server should handle chunk requests from clients: - -```ocaml -# run @@ fun () -> - Eio_mock.Flow.on_read socket [ - `Return "POST /handle_chunk HTTP/1.1\r\n"; - `Return "Content-Type: text/plain\r\n"; - `Return "Transfer-Encoding: chunked\r\n"; - `Return "Trailer: Expires, Header1\r\n\r\n"; - `Return "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n"; - `Return "Mozilla\r\n"; - `Return "9\r\n"; - `Return "Developer\r\n"; - `Return "7\r\n"; - `Return "Network\r\n"; - `Return "0\r\n"; - `Return "Expires: Wed, 31 Oct 2015 07:28:00 GMT\r\n"; - `Return "Header1: Header1 value text\r\n"; - `Return "Header2: Header2 value text\r\n\r\n"; - `Raise End_of_file; - ];; -+socket: read "POST /handle_chunk HTTP/1.1\r\n" -+socket: read "Content-Type: text/plain\r\n" -+socket: read "Transfer-Encoding: chunked\r\n" -+socket: read "Trailer: Expires, Header1\r\n" -+ "\r\n" -+socket: read "7;ext1=ext1_v;ext2=ext2_v;ext3\r\n" -+socket: read "Mozilla\r\n" -+socket: read "9\r\n" -+socket: read "Developer\r\n" -+socket: read "7\r\n" -+socket: read "Network\r\n" -+socket: read "0\r\n" -+socket: read "Expires: Wed, 31 Oct 2015 07:28:00 GMT\r\n" -+socket: read "Header1: Header1 value text\r\n" -+socket: read "Header2: Header2 value text\r\n" -+ "\r\n" -+socket: wrote "HTTP/1.1 200 OK\r\n" -+ "Date: Mon, 24 Oct 2022 16:12:15 GMT\r\n" -+ "content-length: 354\r\n" -+ "content-type: text/plain; charset=UTF-8\r\n" -+ "\r\n" -+ "meth: POST\n" -+ "resource: /handle_chunk\n" -+ "version: HTTP/1.1\n" -+ "headers: Header {\n" -+ " Content-Length = \"23\"; Header1 = \"Header1 value text\";\n" -+ " Content-Type = \"text/plain\" }\n" -+ "\n" -+ "size: 7\n" -+ " data: Mozilla\n" -+ " extensions:\n" -+ " name: ext1\n" -+ " value: ext1_v;\n" -+ " name: ext2\n" -+ " value: ext2_v;\n" -+ " name: ext3\n" -+ " value: \n" -+ "size: 9\n" -+ " data: Developer\n" -+ " extensions: \n" -+ "size: 7\n" -+ " data: Network\n" -+ " extensions: \n" -- : unit = () -``` diff --git a/cohttp-eio/tests/server_chunks.txt b/cohttp-eio/tests/server_chunks.txt deleted file mode 100644 index 94edd3e041..0000000000 --- a/cohttp-eio/tests/server_chunks.txt +++ /dev/null @@ -1,3 +0,0 @@ -Mozilla -Developer -Network diff --git a/cohttp-eio/tests/test.ml b/cohttp-eio/tests/test.ml new file mode 100644 index 0000000000..21260a1fa4 --- /dev/null +++ b/cohttp-eio/tests/test.ml @@ -0,0 +1,89 @@ +let handler request body = + match Http.Request.resource request with + | "/" -> (Http.Response.make (), Cohttp_eio.Body.of_string "root") + | "/stream" -> + let body = Eio_mock.Flow.make "streaming body" in + let () = + Eio_mock.Flow.on_read body + [ `Return "Hello"; `Yield_then (`Return "World") ] + in + (Http.Response.make (), (body :> Eio.Flow.source)) + | "/post" -> (Http.Response.make (), body) + | _ -> (Http.Response.make ~status:`Not_found (), Cohttp_eio.Body.of_string "") + +let () = + Eio_main.run @@ fun env -> + Eio.Switch.run @@ fun sw -> + let server = Cohttp_eio.Server.make env#net ~sw ~port:4242 handler in + let () = + Eio.Fiber.fork_daemon ~sw @@ fun () -> Cohttp_eio.Server.run server + in + let test_case name f = + let f () = + let socket = + Eio.Net.connect ~sw env#net (`Tcp (Eio.Net.Ipaddr.V4.loopback, 4242)) + in + f socket + in + Alcotest.test_case name `Quick f + in + let root socket = + let () = + Eio.Flow.write socket [ Cstruct.of_string "GET / HTTP/1.1\r\n\r\n" ] + in + Alcotest.(check ~here:[%here] string) + "response" "HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\nroot" + Eio.Buf_read.(of_flow ~max_size:max_int socket |> take_all) + and missing socket = + let () = + Eio.Flow.write socket + [ Cstruct.of_string "GET /missing HTTP/1.1\r\n\r\n" ] + in + Alcotest.(check ~here:[%here] string) + "response" "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n" + Eio.Buf_read.(of_flow ~max_size:max_int socket |> take_all) + and streaming_response socket = + let () = + Eio.Flow.write socket [ Cstruct.of_string "GET /stream HTTP/1.1\r\n\r\n" ] + in + Alcotest.(check ~here:[%here] string) + "response" + "HTTP/1.1 200 OK\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + 5\r\n\ + Hello\r\n\ + 5\r\n\ + World\r\n\ + 0\r\n\ + \r\n" + Eio.Buf_read.(of_flow ~max_size:max_int socket |> take_all) + and request_body socket = + let () = + Eio.Flow.write socket + [ + Cstruct.of_string + "POST /post HTTP/1.1\r\ncontent-length:12\r\n\r\nhello world!"; + ] + in + Alcotest.(check ~here:[%here] string) + "response" + "HTTP/1.1 200 OK\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + c\r\n\ + hello world!\r\n\ + 0\r\n\ + \r\n" + Eio.Buf_read.(of_flow ~max_size:max_int socket |> take_all) + in + Alcotest.run "cohttp-eio" + [ + ( "cohttp-eio server", + [ + test_case "root" root; + test_case "missing" missing; + test_case "streaming response" streaming_response; + test_case "request body" request_body; + ] ); + ] diff --git a/dune-project b/dune-project index 428b65ca78..d9d6826062 100644 --- a/dune-project +++ b/dune-project @@ -86,28 +86,30 @@ (package (name cohttp-lwt-unix) (synopsis "CoHTTP implementation for Unix and Windows using Lwt") - (description "\ -An implementation of an HTTP client and server using the Lwt -concurrency library. See the `Cohttp_lwt_unix` module for information -on how to use this. The package also installs `cohttp-curl-lwt` -and a `cohttp-server-lwt` binaries for quick uses of a HTTP(S) -client and server respectively. - -Although the name implies that this only works under Unix, it -should also be fine under Windows too. -") + (description + "An implementation of an HTTP client and server using the Lwt\nconcurrency library. See the `Cohttp_lwt_unix` module for information\non how to use this. The package also installs `cohttp-curl-lwt`\nand a `cohttp-server-lwt` binaries for quick uses of a HTTP(S)\nclient and server respectively.\n\nAlthough the name implies that this only works under Unix, it\nshould also be fine under Windows too.\n") (depends - (ocaml (>= 4.08)) - (http (= :version)) - (cohttp (= :version)) - (cohttp-lwt (= :version)) - (cmdliner (>= 1.1.0)) - (lwt (>= 3.0.0)) - (conduit-lwt (>= 5.0.0)) - (conduit-lwt-unix (>= 5.0.0)) - (fmt (>= 0.8.2)) + (ocaml + (>= 4.08)) + (http + (= :version)) + (cohttp + (= :version)) + (cohttp-lwt + (= :version)) + (cmdliner + (>= 1.1.0)) + (lwt + (>= 3.0.0)) + (conduit-lwt + (>= 5.0.0)) + (conduit-lwt-unix + (>= 5.0.0)) + (fmt + (>= 0.8.2)) base-unix - (ppx_sexp_conv (>= v0.13.0)) + (ppx_sexp_conv + (>= v0.13.0)) magic-mime logs (ounit :with-test))) @@ -253,7 +255,10 @@ should also be fine under Windows too. :with-test (>= v0.13.0))) (core_bench :with-test) - (crowbar (and :with-test (>= 0.2))) + (crowbar + (and + :with-test + (>= 0.2))) (sexplib0 :with-test))) (package @@ -318,7 +323,8 @@ should also be fine under Windows too. (cohttp-curl (= :version)) core - (core_unix (>= v0.14.0)) + (core_unix + (>= v0.14.0)) async_kernel async_unix (cohttp-async @@ -361,7 +367,10 @@ should also be fine under Windows too. "A CoHTTP server and client implementation based on `eio` library. `cohttp-eio`features a multicore capable HTTP 1.1 server. The library promotes and is built with direct style of coding as opposed to a monadic.") (depends base-domains - (eio (>= 0.7)) + (cohttp + (= :version)) + (eio + (>= 0.7)) (eio_main :with-test) (mdx :with-test) (uri :with-test)