├── .gitignore ├── .ocamlformat ├── LICENSE ├── Makefile ├── README.md ├── bin ├── dune └── spring.ml ├── dune ├── dune-project ├── examples ├── dune ├── hello │ ├── dune │ ├── dune-project │ ├── hello.ml │ ├── hello.opam │ ├── master.key │ ├── public │ │ ├── css │ │ │ └── normalize.css │ │ ├── hello.txt │ │ └── index.html │ └── v │ │ ├── dune │ │ ├── hello_v.ohtml │ │ ├── layout_v.ohtml │ │ └── products_v.ohtml └── https_server.ml ├── flake.lock ├── flake.nix ├── lib_ohtml ├── doc.ml ├── dune ├── lexer.mll ├── ohtml.ml ├── ohtml.mli └── parser.mly ├── lib_spring ├── body.ml ├── body.mli ├── buf_read.ml ├── buf_read.mli ├── cache_control.ml ├── cache_control.mli ├── chunked.ml ├── chunked.mli ├── client.ml ├── client.mli ├── content_disposition.ml ├── content_disposition.mli ├── content_type.ml ├── content_type.mli ├── cookie.ml ├── cookie.mli ├── cookie_name_prefix.ml ├── cookie_name_prefix.mli ├── csrf.ml ├── csrf.mli ├── date.ml ├── date.mli ├── dune ├── etag.ml ├── etag.mli ├── expires.ml ├── expires.mli ├── file_handler.ml ├── file_handler.mli ├── headers.ml ├── headers.mli ├── host.ml ├── host.mli ├── if_none_match.ml ├── if_none_match.mli ├── method.ml ├── method.mli ├── multipart.ml ├── multipart.mli ├── ohtml.ml ├── ohtml.mli ├── option.ml ├── request.ml ├── request.mli ├── response.ml ├── response.mli ├── route_ppx.ml ├── route_ppx.mli ├── router.ml ├── router.mli ├── secret.ml ├── server.ml ├── server.mli ├── session.ml ├── session.mli ├── set_cookie.ml ├── set_cookie.mli ├── spring.ml ├── spring.mli ├── status.ml ├── status.mli ├── string.ml ├── te.ml ├── te.mli ├── transfer_encoding.ml ├── transfer_encoding.mli ├── uri.ml ├── uri.mli ├── version.ml └── version.mli ├── spring.opam └── test ├── body.md ├── buf_read.md ├── cache_control.md ├── certificates ├── server.key └── server.pem ├── chunked.md ├── client.md ├── content_disposition.md ├── content_type.md ├── cookie.md ├── cookie_name_prefix.md ├── csrf.md ├── date.md ├── dune ├── etag.md ├── expires.md ├── headers.md ├── host.md ├── if_none_match.md ├── method.md ├── multipart.md ├── ohtml.md ├── request.md ├── response.md ├── router.md ├── router_test.ml ├── server.md ├── session.md ├── set_cookie.md ├── status.md ├── te.md ├── transfer_encoding.md ├── uri.md └── version.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /.ocamlformat: -------------------------------------------------------------------------------- 1 | version=0.26.1 2 | profile=conventional 3 | break-separators=before 4 | dock-collection-brackets=false 5 | let-and=sparse 6 | type-decl=sparse 7 | cases-exp-indent=2 8 | break-cases=fit-or-vertical 9 | break-fun-decl=fit-or-vertical 10 | break-infix=fit-or-vertical 11 | parse-docstrings=true 12 | module-item-spacing=sparse 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: lock 3 | lock: 4 | nix develop -f default.nix lock 5 | 6 | .PHONY: shell 7 | shell: 8 | nix develop -f default.nix -j auto -i -k TERM -k PATH -k HOME -v shell 9 | 10 | .PHONY: build 11 | build: 12 | nix build -f default.nix -j auto -v 13 | 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring 2 | 3 | A Delightful OCaml web programming library. 4 | 5 | ### Hightlights: 6 | 7 | - [x] `ohtml` - a fast, compiled view engine allowing you to mix HTML with OCaml code 8 | - [x] Type safe, radix-tree based url routing engine. Use ppx to specify route path, e.g. `[%r "/store/products/:int"]` 9 | - [x] Form handling/data upload (multipart/formdata protocol - RFC 9110 - for standards compliance and interoperability) 10 | - [x] CSRF form protection (Anti CSRF mechanism) 11 | - [x] Secure HTTP session based on encrypted cookie 12 | - [ ] Secure HTTP session based on SQLite/Postgres/Mysql 13 | - [x] HTTP Cookies (RFC 6265) 14 | - [x] Type-safe HTTP header manipulation 15 | - [x] Fullly compliant (RFC 7230) HTTP chunked transfer protocol (both client and server) 16 | - [x] HTTP file server - host/serve static web assets such as `.css, .js, .jpg, .png` etc 17 | - [x] HTTP/1.1 (RFC 9112) multicore/parallel server/client 18 | - [x] HTTPS/1.1 server/client (TLS/1.3) 19 | - [x] Closely aligned with `eio` io library 20 | 21 | ### Hello world in Spring [^1] 22 | [^1]: See https://github.com/bikallem/spring/tree/main/examples/hello for full sample 23 | 24 | ```hello.ml``` 25 | 26 | ```ocaml 27 | open Spring 28 | 29 | let say_hello _req = V.view ~title:"Hello Page" V.hello_v 30 | 31 | let display_products _req = 32 | V.products_v [ "apple"; "oranges"; "bananas" ] 33 | |> V.view ~title:"Products Page" 34 | 35 | let () = 36 | Eio_main.run @@ fun env -> 37 | Server.app_server ~on_error:raise env#clock env#net 38 | |> Server.get [%r "/"] say_hello 39 | |> Server.get [%r "/products"] display_products 40 | |> Server.run_local ~port:8080 41 | ``` 42 | 43 | ```hello_v.ohtml``` 44 | 45 | ```html 46 | Hello world! 47 | ``` 48 | 49 | ```layout_v.ohtml``` 50 | 51 | ```html 52 | fun ~title ~body -> 53 | 54 | 55 | 56 |
57 |40 | routes-syntax = http-path ["?" http-query] 41 | http-path = "/" wtr-segment 42 | wtr-segment = wtr-arg / rest / wildcard / [segment-nz *( "/" segment)] 43 | wtr-arg = ":int" / ":int32" / ":int64" / ":float" / ":bool" / ":string" / custom-arg 44 | custom-arg = ":" ocaml-module-path 45 | 46 | ocaml-module-path = module-name *("." module-name) ; OCaml module path 47 | ocaml-module-name = (A-Z) *( ALPHA / DIGIT / "_" / "'" ) ; OCaml module name 48 | 49 | rest = "**" 50 | wildcard = "*" 51 | segment = *pchar 52 | segment-nz = 1*pchar 53 | pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 54 | unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 55 | pct-encoded = "%" HEXDIG HEXDIG 56 | sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" 57 | 58 | http-query = query-key-value *("&" query-key-value) 59 | query-key-value = query-name "=" query-value 60 | query-value = 1*pchar / wtr-arg 61 | query-name = 1( pchar / "/" / "?" ) 62 | qchar = unreserved / pct-encoded / qsub-delims / ":" / "@" 63 | qsub-delims = "!" / "$" / "'" / "(" / ")" / "*" / "+" / "," / ";" 64 | 65 | ALPHA = %x41-5A / %x61-7A ; A-Z / a-z 66 | DIGIT = %x30-39 ; 0-9 67 | HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" 68 | 69 | %} 70 | 71 | {2 wtr-segment} 72 | 73 | - rest[(**)] is {!val:Wtr.rest} 74 | - wildcard[(\*\)] is {!val:Wtr.string} 75 | 76 | {2 wtr-arg} 77 | 78 | - [:int] - is {!val:Wtr.int} when used in path and {!val:Wtr.qint} when used 79 | in query 80 | - [:int32] - is {!val:Wtr.int32} when used in path and {!val:Wtr.qint32} 81 | when used in query 82 | - [:int64] - is {!val:Wtr.int64} when used in path and {!val:Wtr.qint64} 83 | when used in query 84 | - [:float] - is {!val:Wtr.float} when used in path and {!val:Wtr.qfloat} 85 | when used in query 86 | - [:bool] - is {!val:Wtr.bool} when used in path and {!val:Wtr.qbool} when 87 | used in query 88 | - [:string] - is {!val:Wtr.string} when used in path and {!val:Wtr.qstring} 89 | when used in query 90 | - [:custom-arg] - is the OCaml module name which implements the user defined 91 | {!type:Wtr.arg} value, e.g. [:Fruit] or [:LibA.Fruit] 92 | 93 | {1:references References} 94 | 95 | + {{:https://datatracker.ietf.org/doc/html/rfc3986#section-3.3} HTTP path} 96 | + {{:https://datatracker.ietf.org/doc/html/rfc3986#section-3.4} HtTP query} 97 | + {{:https://datatracker.ietf.org/doc/html/rfc5234#section-3.6} ABNF} *) 98 | -------------------------------------------------------------------------------- /lib_spring/secret.ml: -------------------------------------------------------------------------------- 1 | let nonce_size = 12 2 | 3 | let encrypt_base64 nonce key contents = 4 | assert (String.length contents > 0); 5 | let key = Cstruct.of_string key in 6 | let key = Mirage_crypto.Chacha20.of_secret key in 7 | let encrypted = 8 | Mirage_crypto.Chacha20.authenticate_encrypt ~key ~nonce 9 | (Cstruct.of_string contents) 10 | in 11 | Cstruct.concat [ nonce; encrypted ] 12 | |> Cstruct.to_string 13 | |> Base64.(encode_string ~pad:false ~alphabet:uri_safe_alphabet) 14 | 15 | let decrypt_base64 key contents = 16 | assert (String.length contents > 0); 17 | let key = Cstruct.of_string key in 18 | let key = Mirage_crypto.Chacha20.of_secret key in 19 | let contents = 20 | Base64.(decode_exn ~pad:false ~alphabet:uri_safe_alphabet contents) 21 | |> Cstruct.of_string 22 | in 23 | let nonce = Cstruct.sub contents 0 nonce_size in 24 | Cstruct.sub contents nonce_size (Cstruct.length contents - nonce_size) 25 | |> Mirage_crypto.Chacha20.authenticate_decrypt ~key ~nonce 26 | |> function 27 | | Some s -> Cstruct.to_string s 28 | | None -> failwith "Unable to decrypt contents" 29 | -------------------------------------------------------------------------------- /lib_spring/session.ml: -------------------------------------------------------------------------------- 1 | module Data = Map.Make (String) 2 | 3 | type nonce = Cstruct.t 4 | 5 | type data = string 6 | 7 | type key = string 8 | 9 | type session_data = string Data.t 10 | 11 | type codec = 12 | { cookie_name : string 13 | ; encode : nonce -> session_data -> data 14 | ; decode : data -> session_data 15 | } 16 | 17 | let[@inline] err () = failwith "Invalid session data" 18 | 19 | let cookie_codec ?(cookie_name = "___SPRING_SESSION___") key = 20 | { cookie_name 21 | ; encode = 22 | (fun nonce session_data -> 23 | Data.to_seq session_data 24 | |> Seq.map (fun (key, v) -> Csexp.(List [ Atom key; Atom v ])) 25 | |> List.of_seq 26 | |> fun l -> 27 | Csexp.List l |> Csexp.to_string |> Secret.encrypt_base64 nonce key) 28 | ; decode = 29 | (fun data -> 30 | let csexp = 31 | match Secret.decrypt_base64 key data |> Csexp.parse_string with 32 | | Ok v -> v 33 | | Error _ -> err () 34 | in 35 | match csexp with 36 | | Csexp.List key_values -> 37 | List.fold_left 38 | (fun acc -> function 39 | | Csexp.(List [ Atom key; Atom value ]) -> Data.add key value acc 40 | | _ -> err ()) 41 | Data.empty key_values 42 | | _ -> err ()) 43 | } 44 | 45 | let cookie_name (t : codec) = t.cookie_name 46 | 47 | let decode data (t : codec) = t.decode data 48 | 49 | let encode ~nonce session_data (t : codec) = t.encode nonce session_data 50 | -------------------------------------------------------------------------------- /lib_spring/session.mli: -------------------------------------------------------------------------------- 1 | (** [Session] implements session functionality in Spring. 2 | 3 | Session can be used to store/retrieve values in a request processing 4 | pipeline. *) 5 | 6 | type nonce = Cstruct.t 7 | (** [nonce] is a 12 byte long randomly generated value. Ensure that this value 8 | is generated from a secure random generation source such as 9 | [Mirage_crypto_rng.generate]. *) 10 | 11 | type data = string 12 | (** [data] is the encrypted data encoded in a session cookie. *) 13 | 14 | type key = string 15 | 16 | module Data : module type of Map.Make (String) 17 | 18 | type session_data = string Data.t 19 | 20 | type codec 21 | (** [codec] encapsulates {!type:session_data} encoding/decoding functionality. *) 22 | 23 | val cookie_codec : ?cookie_name:string -> key -> codec 24 | (** [cookie_codec key] is a cookie based session [t]. A cookie based session 25 | encodes all session data into a session cookie. The session [data] is 26 | encrypted/decrypted with [key]. 27 | 28 | @param cookie_name 29 | is the cookie name used by [t] to encode/decode session data to/from 30 | respectively. The default value is [___SPRING_SESSION___]. *) 31 | 32 | val cookie_name : codec -> string 33 | (** [cookie_name t] is the name of the session cookie in [t]. *) 34 | 35 | val decode : data -> codec -> session_data 36 | (** [decode data t] decodes [data] to [session_data] using [t]. *) 37 | 38 | val encode : nonce:Cstruct.t -> session_data -> codec -> data 39 | (** [encode ~nonce t] encrypts session [t] with a nonce value [nonce]. *) 40 | -------------------------------------------------------------------------------- /lib_spring/spring.ml: -------------------------------------------------------------------------------- 1 | module Version = Version 2 | module Method = Method 3 | module Status = Status 4 | module Uri = Uri 5 | 6 | (* Header *) 7 | module Te = Te 8 | module Transfer_encoding = Transfer_encoding 9 | module Date = Date 10 | module Content_type = Content_type 11 | module Content_disposition = Content_disposition 12 | module Cookie_name_prefix = Cookie_name_prefix 13 | module Cookie = Cookie 14 | module Set_cookie = Set_cookie 15 | module Expires = Expires 16 | module Etag = Etag 17 | module If_none_match = If_none_match 18 | module Cache_control = Cache_control 19 | module Host = Host 20 | module Headers = Headers 21 | 22 | (* Body *) 23 | module Body = Body 24 | module Chunked = Chunked 25 | module Multipart = Multipart 26 | 27 | (* Others *) 28 | module Request = Request 29 | module Response = Response 30 | module Client = Client 31 | module Server = Server 32 | module Router = Router 33 | module Session = Session 34 | module Csrf = Csrf 35 | 36 | (* Ohtml *) 37 | module Ohtml = Ohtml 38 | -------------------------------------------------------------------------------- /lib_spring/spring.mli: -------------------------------------------------------------------------------- 1 | module Version = Version 2 | module Method = Method 3 | module Status = Status 4 | module Uri = Uri 5 | 6 | (** {1 Header} *) 7 | 8 | module Te = Te 9 | module Transfer_encoding = Transfer_encoding 10 | module Date = Date 11 | module Content_type = Content_type 12 | module Content_disposition = Content_disposition 13 | module Set_cookie = Set_cookie 14 | module Cookie_name_prefix = Cookie_name_prefix 15 | module Cookie = Cookie 16 | module Expires = Expires 17 | module Etag = Etag 18 | module If_none_match = If_none_match 19 | module Cache_control = Cache_control 20 | module Host = Host 21 | module Headers = Headers 22 | 23 | (** {1 Body} *) 24 | 25 | module Body = Body 26 | module Chunked = Chunked 27 | module Multipart = Multipart 28 | 29 | (* {1 Request} *) 30 | 31 | module Request = Request 32 | 33 | (** {1 Response} *) 34 | 35 | module Response = Response 36 | 37 | (** {1 Client} *) 38 | 39 | module Client = Client 40 | 41 | (** {1 Server} *) 42 | 43 | module Server = Server 44 | module Router = Router 45 | module Csrf = Csrf 46 | 47 | (** {1 Ohtml} *) 48 | 49 | module Session = Session 50 | module Ohtml = Ohtml 51 | -------------------------------------------------------------------------------- /lib_spring/status.ml: -------------------------------------------------------------------------------- 1 | type t = int * string 2 | 3 | let make code phrase = 4 | if code < 0 then failwith (Printf.sprintf "code: %d is negative" code) 5 | else if code < 100 || code > 999 then 6 | failwith (Printf.sprintf "code: %d is not a three-digit number" code) 7 | else (code, phrase) 8 | 9 | (* Informational *) 10 | let continue = (100, "Continue") 11 | 12 | let switching_protocols = (101, "Switching Protocols") 13 | 14 | let processing = (102, "Processing") 15 | 16 | let early_hints = (103, "Early Hints") 17 | 18 | (* Successful *) 19 | 20 | let ok = (200, "OK") 21 | 22 | let created = (201, "Created") 23 | 24 | let accepted = (202, "Accepted") 25 | 26 | let non_authoritative_information = (203, "Non-Authoritative Information") 27 | 28 | let no_content = (204, "No Content") 29 | 30 | let reset_content = (205, "Reset Content") 31 | 32 | let partial_content = (206, "Partial Content") 33 | (* Redirection *) 34 | 35 | let multiple_choices = (300, "Multiple Choices") 36 | 37 | let moved_permanently = (301, "Moved Permanently") 38 | 39 | let found = (302, "Found") 40 | 41 | let see_other = (303, "See Other") 42 | 43 | let not_modified = (304, "Not Modified") 44 | 45 | let use_proxy = (305, "Use Proxy") 46 | 47 | let temporary_redirect = (306, "Temporary Redirect") 48 | 49 | (* Client error *) 50 | let bad_request = (400, "Bad Request") 51 | 52 | let unauthorized = (401, "Unauthorized") 53 | 54 | let payment_required = (402, "Payment Required") 55 | 56 | let forbidden = (403, "Forbidden") 57 | 58 | let not_found = (404, "Not Found") 59 | 60 | let method_not_allowed = (405, "Method Not Allowed") 61 | 62 | let not_acceptable = (406, "Not Acceptable") 63 | 64 | let proxy_authentication_required = (407, "Proxy Authentication Required") 65 | 66 | let request_timeout = (408, "Request Timeout") 67 | 68 | let conflict = (409, "Conflict") 69 | 70 | let gone = (410, "Gone") 71 | 72 | let length_required = (411, "Length Required") 73 | 74 | let precondition_failed = (412, "Precondition Failed") 75 | 76 | let content_too_large = (413, "Payload Too Large") 77 | 78 | let uri_too_long = (414, "URI Too Long") 79 | 80 | let unsupported_media_type = (415, "Unsupported Media Type") 81 | 82 | let range_not_satisfiable = (416, "Range Not Satisfiable") 83 | 84 | let expectation_failed = (417, "Expectation Failed") 85 | 86 | let misdirected_request = (421, "Misdirected Request") 87 | 88 | let unprocessable_content = (422, "Unprocessable Content") 89 | 90 | let locked = (423, "Locked") 91 | 92 | let failed_dependency = (424, "Failed Dependency") 93 | 94 | let too_early = (425, "Too Early") 95 | 96 | let upgrade_required = (426, "Upgrade Required") 97 | 98 | let unassigned = (427, "Unassigned") 99 | 100 | let precondition_required = (428, "Precondition Required") 101 | 102 | let too_many_requests = (429, "Too Many Requests") 103 | 104 | let request_header_fields_too_large = (431, "Request Header Fields Too Large") 105 | 106 | let unavailable_for_legal_reasons = (451, "Unavailable For Legal Reasons") 107 | 108 | (* Server error *) 109 | let internal_server_error = (500, "Internal Server Error") 110 | 111 | let not_implemented = (501, "Not Implemented") 112 | 113 | let bad_gateway = (502, "Bad Gateway") 114 | 115 | let service_unavilable = (503, "Service Unavailable") 116 | 117 | let gateway_timeout = (504, "Gateway Timeout") 118 | 119 | let http_version_not_supported = (505, "HTTP Version Not Supported") 120 | 121 | let variant_also_negotiates = (506, "Variant Also Negotiates") 122 | 123 | let insufficient_storage = (507, "Insufficient Storage") 124 | 125 | let loop_detected = (508, "Loop Detected") 126 | 127 | let network_authentication_required = (511, "Network Authentication Required") 128 | 129 | let informational (code, _) = code >= 100 && code <= 103 130 | 131 | let server_error (code, _) = code >= 500 && code <= 511 132 | 133 | let equal (code_a, _) (code_b, _) = code_a = code_b 134 | 135 | let to_string (code, phrase) = string_of_int code ^ " " ^ phrase 136 | 137 | let pp fmt t = Format.fprintf fmt "%s" (to_string t) 138 | -------------------------------------------------------------------------------- /lib_spring/status.mli: -------------------------------------------------------------------------------- 1 | type t = private int * string 2 | 3 | val make : int -> string -> t 4 | (** [make code phrase] is [t] with status code [code] and status phrase 5 | [phrase]. *) 6 | 7 | (** Informational *) 8 | 9 | val continue : t 10 | 11 | val switching_protocols : t 12 | 13 | val processing : t 14 | 15 | val early_hints : t 16 | 17 | (** Successful *) 18 | 19 | val ok : t 20 | 21 | val created : t 22 | 23 | val accepted : t 24 | 25 | val non_authoritative_information : t 26 | 27 | val no_content : t 28 | 29 | val reset_content : t 30 | 31 | val partial_content : t 32 | 33 | (** Redirection *) 34 | 35 | val multiple_choices : t 36 | 37 | val moved_permanently : t 38 | 39 | val found : t 40 | 41 | val see_other : t 42 | 43 | val not_modified : t 44 | 45 | val use_proxy : t 46 | 47 | val temporary_redirect : t 48 | 49 | (** Client error *) 50 | 51 | val bad_request : t 52 | 53 | val unauthorized : t 54 | 55 | val payment_required : t 56 | 57 | val forbidden : t 58 | 59 | val not_found : t 60 | 61 | val method_not_allowed : t 62 | 63 | val not_acceptable : t 64 | 65 | val proxy_authentication_required : t 66 | 67 | val request_timeout : t 68 | 69 | val conflict : t 70 | 71 | val gone : t 72 | 73 | val length_required : t 74 | 75 | val precondition_failed : t 76 | 77 | val content_too_large : t 78 | 79 | val uri_too_long : t 80 | 81 | val unsupported_media_type : t 82 | 83 | val range_not_satisfiable : t 84 | 85 | val expectation_failed : t 86 | 87 | val misdirected_request : t 88 | 89 | val unprocessable_content : t 90 | 91 | val locked : t 92 | 93 | val failed_dependency : t 94 | 95 | val too_early : t 96 | 97 | val upgrade_required : t 98 | 99 | val unassigned : t 100 | 101 | val precondition_required : t 102 | 103 | val too_many_requests : t 104 | 105 | val request_header_fields_too_large : t 106 | 107 | val unavailable_for_legal_reasons : t 108 | 109 | (** Server error *) 110 | 111 | val internal_server_error : t 112 | 113 | val not_implemented : t 114 | 115 | val bad_gateway : t 116 | 117 | val service_unavilable : t 118 | 119 | val gateway_timeout : t 120 | 121 | val http_version_not_supported : t 122 | 123 | val variant_also_negotiates : t 124 | 125 | val insufficient_storage : t 126 | 127 | val loop_detected : t 128 | 129 | val network_authentication_required : t 130 | 131 | (** {1 Status} *) 132 | 133 | val informational : t -> bool 134 | 135 | val server_error : t -> bool 136 | 137 | val equal : t -> t -> bool 138 | 139 | val to_string : t -> string 140 | 141 | val pp : Format.formatter -> t -> unit 142 | -------------------------------------------------------------------------------- /lib_spring/string.ml: -------------------------------------------------------------------------------- 1 | include Astring.String 2 | -------------------------------------------------------------------------------- /lib_spring/te.ml: -------------------------------------------------------------------------------- 1 | type directive = string 2 | 3 | type q = string 4 | 5 | module M = Set.Make (struct 6 | type t = directive * q option 7 | 8 | let compare ((d1, _) : t) ((d2, _) : t) = 9 | match (d1, d2) with 10 | | "trailers", "trailers" -> 0 11 | | "trailers", _ -> -1 12 | | _, "trailers" -> 1 13 | | _, _ -> Stdlib.compare d1 d2 14 | end) 15 | 16 | let directive = Fun.id 17 | 18 | let trailers = "trailers" 19 | 20 | let compress = "compress" 21 | 22 | let deflate = "deflate" 23 | 24 | let gzip = "gzip" 25 | 26 | type t = M.t 27 | 28 | let singleton ?q d = M.singleton (d, q) 29 | 30 | let exists t d = M.mem (d, None) t 31 | 32 | let add ?q t d = M.add (d, q) t 33 | 34 | let get_q t d : q option = 35 | match M.find_opt (d, None) t with 36 | | Some (_, q) -> q 37 | | None -> None 38 | 39 | let remove t d = M.remove (d, None) t 40 | 41 | let iter f t = M.iter (fun (d, q) -> f d q) t 42 | 43 | let encode t = 44 | let q_to_str = function 45 | | Some q -> ";q=" ^ q 46 | | None -> "" 47 | in 48 | M.to_seq t 49 | |> List.of_seq 50 | |> List.map (fun (d, q) -> d ^ q_to_str q) 51 | |> String.concat ~sep:", " 52 | 53 | open Buf_read.Syntax 54 | open Buf_read 55 | 56 | let is_q_value = function 57 | | '0' .. '9' -> true 58 | | '.' -> true 59 | | _ -> false 60 | 61 | let p_directive = 62 | let parse_qval () = 63 | let* ch = peek_char in 64 | match ch with 65 | | Some ';' -> 66 | let+ v = char ';' *> ows *> string "q=" *> take_while1 is_q_value in 67 | Some v 68 | | _ -> return None 69 | in 70 | let* directive = token <* ows in 71 | let+ q = 72 | match directive with 73 | | "trailers" -> return None 74 | | _ -> parse_qval () 75 | in 76 | (directive, q) 77 | 78 | let decode v = 79 | let r = Buf_read.of_string v in 80 | let d = p_directive r in 81 | let rec aux () = 82 | match peek_char r with 83 | | Some ',' -> 84 | let d = (char ',' *> ows *> p_directive) r in 85 | d :: aux () 86 | | _ -> [] 87 | in 88 | M.of_list (d :: aux ()) 89 | -------------------------------------------------------------------------------- /lib_spring/te.mli: -------------------------------------------------------------------------------- 1 | (** [Te] implements TE header specification at 2 | https://httpwg.org/specs/rfc9110.html#rfc.section.10.1.4 *) 3 | 4 | type directive 5 | 6 | type q = string 7 | (** [q] is the q value as specified at 8 | https://httpwg.org/specs/rfc9110.html#rfc.section.12.4.2 *) 9 | 10 | type t 11 | (** [t] holds TE header values. *) 12 | 13 | (** {1 Directives} *) 14 | 15 | val directive : string -> directive 16 | (** [directive name] is [directive]. *) 17 | 18 | val trailers : directive 19 | 20 | val compress : directive 21 | 22 | val deflate : directive 23 | 24 | val gzip : directive 25 | 26 | (** {1 Exists, Add/Remove} *) 27 | 28 | val singleton : ?q:q -> directive -> t 29 | 30 | val exists : t -> directive -> bool 31 | 32 | val add : ?q:q -> t -> directive -> t 33 | 34 | val get_q : t -> directive -> q option 35 | 36 | val remove : t -> directive -> t 37 | 38 | (** {1 Iter} *) 39 | 40 | val iter : (directive -> q option -> unit) -> t -> unit 41 | 42 | (** {1 Codec} *) 43 | 44 | val encode : t -> string 45 | 46 | val decode : string -> t 47 | -------------------------------------------------------------------------------- /lib_spring/transfer_encoding.ml: -------------------------------------------------------------------------------- 1 | type encoding = string 2 | 3 | module M = Set.Make (struct 4 | type t = encoding 5 | 6 | (** `chunked at the last *) 7 | let compare (a : encoding) (b : encoding) = 8 | match (a, b) with 9 | | "chunked", "chunked" -> 0 10 | | "chunked", _ -> 1 11 | | _, "chunked" -> -1 12 | | _ -> String.compare a b 13 | end) 14 | 15 | type t = M.t 16 | 17 | let encoding s = s 18 | 19 | let compress = "compress" 20 | 21 | let deflate = "deflate" 22 | 23 | let gzip = "gzip" 24 | 25 | let chunked = "chunked" 26 | 27 | let singleton enc = M.singleton enc 28 | 29 | let is_empty = M.is_empty 30 | 31 | let exists t d = M.mem d t 32 | 33 | let add t d = M.add d t 34 | 35 | let remove t d = M.remove d t 36 | 37 | let iter = M.iter 38 | 39 | let encode t = M.to_seq t |> List.of_seq |> String.concat ~sep:", " 40 | 41 | let decode v = 42 | String.cuts ~sep:"," v 43 | |> List.map String.trim 44 | |> List.filter (fun s -> s <> "") 45 | |> List.fold_left (fun t te -> M.add te t) M.empty 46 | -------------------------------------------------------------------------------- /lib_spring/transfer_encoding.mli: -------------------------------------------------------------------------------- 1 | (** HTTP [Transfer-Encoding] header. 2 | 3 | See {{!https://www.rfc-editor.org/rfc/rfc9112#name-transfer-encoding} 4 | Transfer-Encoding}. *) 5 | 6 | type t 7 | 8 | (** {1 Encoding} *) 9 | 10 | type encoding 11 | (** [encoding] is HTTP encoding. *) 12 | 13 | val encoding : string -> encoding 14 | 15 | val compress : encoding 16 | 17 | val deflate : encoding 18 | 19 | val gzip : encoding 20 | 21 | val chunked : encoding 22 | 23 | (** {1 Add, Remove, Find} *) 24 | 25 | val singleton : encoding -> t 26 | 27 | val is_empty : t -> bool 28 | 29 | val exists : t -> encoding -> bool 30 | 31 | val add : t -> encoding -> t 32 | 33 | val remove : t -> encoding -> t 34 | 35 | val iter : (encoding -> unit) -> t -> unit 36 | 37 | (** {1 Codec} *) 38 | 39 | val encode : t -> string 40 | 41 | val decode : string -> t 42 | -------------------------------------------------------------------------------- /lib_spring/version.ml: -------------------------------------------------------------------------------- 1 | type t = int * int (* major, minor *) 2 | 3 | let make ~major ~minor = (major, minor) 4 | 5 | let http1_1 = (1, 1) 6 | 7 | let http1_0 = (1, 0) 8 | 9 | let equal (a : t) (b : t) = a = b 10 | 11 | let to_string (major, minor) = 12 | "HTTP/" ^ string_of_int major ^ "." ^ string_of_int minor 13 | 14 | let pp fmt t = Format.fprintf fmt "%s" @@ to_string t 15 | 16 | let parse = 17 | let open Buf_read.Syntax in 18 | let* major = 19 | Buf_read.string "HTTP/" *> Buf_read.any_char <* Buf_read.char '.' 20 | in 21 | let* minor = Buf_read.any_char in 22 | match (major, minor) with 23 | | '1', '1' -> Buf_read.return http1_1 24 | | '1', '0' -> Buf_read.return http1_0 25 | | _ -> ( 26 | try 27 | let major = Char.escaped major |> int_of_string in 28 | let minor = Char.escaped minor |> int_of_string in 29 | Buf_read.return (make ~major ~minor) 30 | with Failure _ -> 31 | failwith (Format.sprintf "Invalid HTTP version: (%c,%c)" major minor)) 32 | -------------------------------------------------------------------------------- /lib_spring/version.mli: -------------------------------------------------------------------------------- 1 | (** HTTP request/response version. *) 2 | 3 | type t = private int * int 4 | (** [t] is HTTP version [(major, minor)] *) 5 | 6 | val make : major:int -> minor:int -> t 7 | (** [make ~major ~minor] is HTTP version [t]. [major], [minor] is the 8 | major/minor HTTP version respectively. *) 9 | 10 | val http1_1 : t 11 | (** [http1_1] is HTTP/1.1 version. *) 12 | 13 | val http1_0 : t 14 | (** [http1_0] is HTTP/1.0 version. *) 15 | 16 | val equal : t -> t -> bool 17 | (** [equal a b] is [true] iff [a] and [b] represents the same HTTP version. 18 | Otherwise it is [false]. *) 19 | 20 | val to_string : t -> string 21 | (** [to_string t] is the string representation of [t]. *) 22 | 23 | val pp : Format.formatter -> t -> unit 24 | (** [pp fmt t] pretty prints [t] onto [fmt]. *) 25 | 26 | val parse : t Buf_read.parser 27 | (** [parse buf_read] parses HTTP version [t] from [buf_read]. *) 28 | -------------------------------------------------------------------------------- /spring.opam: -------------------------------------------------------------------------------- 1 | # This file is generated by dune, edit dune-project instead 2 | opam-version: "2.0" 3 | synopsis: "Web programming library" 4 | description: "A delightful web programming library" 5 | maintainer: ["gbikal@gmail.com"] 6 | authors: ["Bikal Gurung"] 7 | license: "MPL-2.0" 8 | homepage: "https://github.com/gbikal/spring" 9 | bug-reports: "https://github.com/gbikal/spring/issues" 10 | depends: [ 11 | "ocaml" {>= "5.1.1"} 12 | "eio" {>= "0.11"} 13 | "eio_main" {>= "0.11"} 14 | "dune" {>= "3.10" & >= "3.9.0"} 15 | "ptime" {>= "1.1.0"} 16 | "astring" {>= "0.8.5"} 17 | "fmt" {>= "0.9.0"} 18 | "domain-name" {>= "0.4.0"} 19 | "menhir" {>= "20230608"} 20 | "cmdliner" {>= "1.1.1"} 21 | "ppxlib" {>= "0.29.1"} 22 | "cstruct" {>= "6.2.0"} 23 | "base64" {>= "3.5.1"} 24 | "csexp" {>= "1.5.2"} 25 | "ipaddr" {>= "5.5.0"} 26 | "mirage-crypto" {>= "0.11.1"} 27 | "mirage-crypto-rng" {>= "0.11.1"} 28 | "mirage-crypto-rng-eio" {>= "0.11.1"} 29 | "fpath" {>= "0.7.3"} 30 | "magic-mime" {>= "1.3.0"} 31 | "tls" {>= "0.17.1"} 32 | "tls-eio" {>= "0.17.1"} 33 | "ca-certs" {>= "0.2.3"} 34 | "x509" {>= "0.16.5"} 35 | "cstruct" {>= "6.1.1" & with-test} 36 | "mdx" {>= "2.3.0" & with-test} 37 | "odoc" {with-doc} 38 | "ocamlformat" {>= "0.26.1" & with-dev-setup} 39 | "utop" {>= "2.13.1" & with-dev-setup} 40 | ] 41 | build: [ 42 | ["dune" "subst"] {dev} 43 | [ 44 | "dune" 45 | "build" 46 | "-p" 47 | name 48 | "-j" 49 | jobs 50 | "@install" 51 | "@runtest" {with-test} 52 | "@doc" {with-doc} 53 | ] 54 | ] 55 | dev-repo: "git+https://github.com/gbikal/spring.git" 56 | -------------------------------------------------------------------------------- /test/body.md: -------------------------------------------------------------------------------- 1 | # Body 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | A `Buffer.t` sink to test `Body.writer`. 8 | 9 | ```ocaml 10 | let test_writer (body: Body.writable) = 11 | Eio_main.run @@ fun env -> 12 | let b = Buffer.create 10 in 13 | let s = Eio.Flow.buffer_sink b in 14 | Eio.Buf_write.with_flow s (fun bw -> 15 | Body.write_headers bw body; 16 | Body.write_body bw body; 17 | ); 18 | Eio.traceln "%s" (Buffer.contents b);; 19 | ``` 20 | 21 | ## writable_content 22 | 23 | ```ocaml 24 | # let content_type = Content_type.make ("text", "plain");; 25 | val content_type : Content_type.t =26 | 27 | # test_writer @@ Body.writable_content content_type "hello world";; 28 | +Content-Length: 11 29 | +Content-Type: text/plain 30 | +hello world 31 | - : unit = () 32 | ``` 33 | 34 | ## writable_form_values 35 | 36 | ```ocaml 37 | # test_writer @@ Body.writable_form_values ["name1", "val a"; "name1", "val b"; "name1", "val c"; "name2", "val c"; "name2", "val d"; "name2", "val e"] ;; 38 | +Content-Length: 83 39 | +Content-Type: application/x-www-form-urlencoded 40 | +name1=val%20a&name1=val%20b&name1=val%20c&name2=val%20c&name2=val%20d&name2=val%20e 41 | - : unit = () 42 | ``` 43 | 44 | ## read_content 45 | 46 | ```ocaml 47 | let test_reader body headers f = 48 | Eio_main.run @@ fun env -> 49 | let buf_read = Eio.Buf_read.of_string body in 50 | let headers = Headers.of_list headers in 51 | let r = Body.make_readable headers buf_read in 52 | f r;; 53 | ``` 54 | 55 | `read_content` reads the contents of a reader if `headers` contains valid `Content-Length` header. 56 | 57 | ```ocaml 58 | # test_reader "hello world" ["Content-Length","11"] Body.read_content ;; 59 | - : string option = Some "hello world" 60 | ``` 61 | 62 | None if 'Content-Length' is not valid. 63 | 64 | ```ocaml 65 | # test_reader "hello world" ["Content-Length","12a"] Body.read_content ;; 66 | - : string option = None 67 | ``` 68 | 69 | Or if it is missing. 70 | 71 | ```ocaml 72 | # test_reader "hello world" [] Body.read_content ;; 73 | - : string option = None 74 | ``` 75 | 76 | ## read_form_values 77 | 78 | The reader below has both "Content-Length" and "Content-Type" header set correctly, so we are able 79 | to parse the body correctly. 80 | 81 | ```ocaml 82 | # let body = "name1=val%20a&name1=val%20b&name1=val%20c&name2=val%20c&name2=val%20d&name2=val%20e" in 83 | test_reader 84 | body 85 | [("Content-Length", (string_of_int (String.length body))); ("Content-Type", "application/x-www-form-urlencoded")] 86 | Body.read_form_values ;; 87 | - : (string * string) list = 88 | [("name1", "val a"); ("name1", "val b"); ("name1", "val c"); 89 | ("name2", "val c"); ("name2", "val d"); ("name2", "val e")] 90 | ``` 91 | 92 | Note that the reader below doesn't have "Content-Type" header. Thus `read_form_values` returns am empty list. 93 | 94 | ```ocaml 95 | # let body = "name1=val%20a&name1=val%20b&name1=val%20c&name2=val%20c&name2=val%20d&name2=val%20e" in 96 | test_reader 97 | body 98 | [("Content-Length", (string_of_int (String.length body)))] 99 | Body.read_form_values ;; 100 | - : (string * string) list = [] 101 | ``` 102 | 103 | Note that the reader below doesn't have "Content-Length" header. Thus `read_form_values` returns am empty list. 104 | 105 | ```ocaml 106 | # let body = "name1=val%20a,val%20b,val%20c&name2=val%20c,val%20d,val%20e" in 107 | test_reader 108 | body 109 | [("Content-Type", "application/x-www-form-urlencoded")] 110 | Body.read_form_values ;; 111 | - : (string * string) list = [] 112 | ``` 113 | -------------------------------------------------------------------------------- /test/buf_read.md: -------------------------------------------------------------------------------- 1 | # Spring.Buf_read 2 | 3 | ```ocaml 4 | open Spring 5 | 6 | module Buf_read = Spring__Buf_read 7 | 8 | let b s = Buf_read.of_string s 9 | ``` 10 | 11 | ## Buf_read.take_while1 12 | 13 | `take_while1` calls given `on_error` function. 14 | 15 | ```ocaml 16 | # Buf_read.take_while1 ~on_error:(fun () -> failwith "invalid name") 17 | (function 'a'..'z' -> true | _ -> false) @@ b "";; 18 | Exception: Failure "invalid name". 19 | ``` 20 | 21 | ## Buf_read.quoted_pair 22 | 23 | ```ocaml 24 | # Buf_read.quoted_pair @@ b {|\"|} ;; 25 | - : char = '"' 26 | 27 | # Buf_read.quoted_pair @@ b {|\\|} ;; 28 | - : char = '\\' 29 | 30 | # Buf_read.quoted_pair @@ b {|\v|} ;; 31 | - : char = 'v' 32 | ``` 33 | 34 | ## Buf_read.quoted_text 35 | 36 | ```ocaml 37 | # Buf_read.quoted_text @@ b "\t";; 38 | - : char = '\t' 39 | 40 | # Buf_read.quoted_text @@ b "a";; 41 | - : char = 'a' 42 | ``` 43 | 44 | ## Buf_read.quoted_string 45 | 46 | ```ocaml 47 | # Buf_read.quoted_string @@ b {|"hello world"|} ;; 48 | - : string = "hello world" 49 | 50 | # Buf_read.quoted_string @@ b {|"hello \" \\world"|} ;; 51 | - : string = "hello \" \\world" 52 | ``` 53 | 54 | ## cookie_pair 55 | 56 | Parse cookie name, value to `SID` and `"hello"`. Note the double quotes on the value. 57 | 58 | ```ocaml 59 | # Buf_read.cookie_pair @@ b {|SID="hello"|};; 60 | - : string * string = ("SID", "\"hello\"") 61 | 62 | # Buf_read.cookie_pair @@ b {|SID=1234|};; 63 | - : string * string = ("SID", "1234") 64 | ``` 65 | 66 | ## list1 67 | 68 | `list1` should parse at least one or more elements. 69 | 70 | Valid cases. 71 | 72 | ```ocaml 73 | # let p = Buf_read.take_while1 (function 'a' .. 'z' -> true | _ -> false);; 74 | val p : string Buf_read.parser = 75 | 76 | # Buf_read.list1 p (Buf_read.of_string "foo, bar");; 77 | - : string list = ["foo"; "bar"] 78 | 79 | # Buf_read.list1 p (Buf_read.of_string "foo ,bar,");; 80 | - : string list = ["foo"; "bar"] 81 | 82 | # Buf_read.list1 p (Buf_read.of_string "foo , ,bar,charlie");; 83 | - : string list = ["foo"; "bar"; "charlie"] 84 | ``` 85 | 86 | Invalid cases - `take_while1` requires at least one character. 87 | 88 | ```ocaml 89 | # Buf_read.list1 p (Buf_read.of_string "");; 90 | Exception: Failure "take_while1". 91 | 92 | # Buf_read.list1 p (Buf_read.of_string ",");; 93 | Exception: Failure "take_while1". 94 | 95 | # Buf_read.list1 p (Buf_read.of_string ", ,");; 96 | Exception: Failure "take_while1". 97 | ``` 98 | 99 | Valid cases - `take_while` allows empty string. 100 | 101 | ```ocaml 102 | # let p = Buf_read.take_while (function 'a' .. 'z' -> true | _ -> false);; 103 | val p : string Buf_read.parser = 104 | 105 | # Buf_read.list1 p (Buf_read.of_string "");; 106 | - : string list = [""] 107 | 108 | # Buf_read.list1 p (Buf_read.of_string ",");; 109 | - : string list = [""] 110 | 111 | # Buf_read.list1 p (Buf_read.of_string ", ,");; 112 | - : string list = [""] 113 | ``` 114 | 115 | ## delta_seconds 116 | 117 | ```ocaml 118 | # Buf_read.(delta_seconds (of_string "234"));; 119 | - : int = 234 120 | 121 | # Buf_read.(delta_seconds (of_string "5"));; 122 | - : int = 5 123 | 124 | # Buf_read.(delta_seconds (of_string ""));; 125 | Exception: Failure "take_while1". 126 | ``` 127 | -------------------------------------------------------------------------------- /test/certificates/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQC2QEje5rwhlD2iq162+Ng3AH9BfA/jNJLDqi9VPk1eMUNGicJv 3 | K+aOANKIsOOr9v4RiEXZSYmFEvGSy+Sf1bCDHwHLLSdNs6Y49b77POgatrVZOTRE 4 | BE/t1soVT3a/vVJWCLtVCjm70u0S5tcfn4S6IapeIYAVAmcaqwSa+GQNoQIDAQAB 5 | AoGAd/CShG8g/JBMh9Nz/8KAuKHRHc2BvysIM1C62cSosgaFmdRrazJfBrEv3Nlc 6 | 2/0uc2dVYIxuvm8bIFqi2TWOdX9jWJf6oXwEPXCD0SaDbJTaoh0b+wjyHuaGlttY 7 | Ztvmf8mK1BOhyl3vNMxh/8Re0dGvGgPZHpn8zanaqfGVz+ECQQDngieUpwzxA0QZ 8 | GZKRYhHoLEaPiQzBaXphqWcCLLN7oAKxZlUCUckxRRe0tKINf0cB3Kr9gGQjPpm0 9 | YoqXo8mNAkEAyYgdd+JDi9FH3Cz6ijvPU0hYkriwTii0V09+Ar5DvYQNzNEIEJu8 10 | Q3Yte/TPRuK8zhnp97Bsy9v/Ji/LSWbtZQJBAJe9y8u3otfmWCBLjrIUIcCYJLe4 11 | ENBFHp4ctxPJ0Ora+mjkthuLF+BfdSZQr1dBcX1a8giuuvQO+Bgv7r9t75ECQC7F 12 | omEyaA7JEW5uGe9/Fgz0G2ph5rkdBU3GKy6jzcDsJu/EC6UfH8Bgawn7tSd0c/E5 13 | Xm2Xyog9lKfeK8XrV2kCQQCTico5lQPjfIwjhvn45ALc/0OrkaK0hQNpXgUNFJFQ 14 | tuX2WMD5flMyA5PCx5XBU8gEMHYa8Kr5d6uoixnbS0cZ 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /test/certificates/server.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICYzCCAcwCCQDLbE6ES1ih1DANBgkqhkiG9w0BAQUFADB2MQswCQYDVQQGEwJB 3 | VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 4 | cyBQdHkgTHRkMRUwEwYDVQQDDAxZT1VSIE5BTUUhISExGDAWBgkqhkiG9w0BCQEW 5 | CW1lQGJhci5kZTAeFw0xNDAyMTcyMjA4NDVaFw0xNTAyMTcyMjA4NDVaMHYxCzAJ 6 | BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l 7 | dCBXaWRnaXRzIFB0eSBMdGQxFTATBgNVBAMMDFlPVVIgTkFNRSEhITEYMBYGCSqG 8 | SIb3DQEJARYJbWVAYmFyLmRlMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC2 9 | QEje5rwhlD2iq162+Ng3AH9BfA/jNJLDqi9VPk1eMUNGicJvK+aOANKIsOOr9v4R 10 | iEXZSYmFEvGSy+Sf1bCDHwHLLSdNs6Y49b77POgatrVZOTREBE/t1soVT3a/vVJW 11 | CLtVCjm70u0S5tcfn4S6IapeIYAVAmcaqwSa+GQNoQIDAQABMA0GCSqGSIb3DQEB 12 | BQUAA4GBAIo4ZppIlp3JRyltRC1/AyCC0tsh5TdM3W7258wdoP3lEe08UlLwpnPc 13 | aJ/cX8rMG4Xf4it77yrbVrU3MumBEGN5TW4jn4+iZyFbp6TT3OUF55nsXDjNHBbu 14 | deDVpGuPTI6CZQVhU5qEMF3xmlokG+VV+HCDTglNQc+fdLM0LoNF 15 | -----END CERTIFICATE----- 16 | -------------------------------------------------------------------------------- /test/chunked.md: -------------------------------------------------------------------------------- 1 | # Chunked 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | A `Buffer.t` sink to test `Body.writer`. 8 | 9 | ```ocaml 10 | let test_writer (body : Body.writable) = 11 | Eio_main.run @@ fun env -> 12 | let b = Buffer.create 10 in 13 | let s = Eio.Flow.buffer_sink b in 14 | Eio.Buf_write.with_flow s (fun bw -> 15 | Body.write_headers bw body; 16 | Body.write_body bw body; 17 | ); 18 | Eio.traceln "%s" (Buffer.contents b);; 19 | ``` 20 | 21 | ## Chunked.writable 22 | 23 | Writes both chunked body and trailer since `ua_supports_trailer:true`. 24 | 25 | ```ocaml 26 | # let write_chunk f = 27 | f @@ Chunked.make ~extensions:["ext1",Some "ext1_v"] "Hello, "; 28 | Eio.Fiber.yield (); 29 | Eio.traceln "Resuming ..."; 30 | f @@ Chunked.make ~extensions:["ext2",None] "world!"; 31 | Eio.Fiber.yield (); 32 | Eio.traceln "Resuming ..."; 33 | f @@ Chunked.make "Again!"; 34 | f @@ Chunked.make "";; 35 | val write_chunk : (Chunked.t -> 'a) -> 'a = 36 | # let write_trailer f = 37 | let trailer_headers = 38 | Headers.of_list 39 | [ 40 | ("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); 41 | ("Header1", "Header1 value text"); 42 | ("Header2", "Header2 value text"); 43 | ] 44 | in 45 | f trailer_headers;; 46 | val write_trailer : (Headers.t -> 'a) -> 'a = 47 | 48 | # test_writer (Chunked.writable ~ua_supports_trailer:true write_chunk write_trailer) ;; 49 | +Resuming ... 50 | +Resuming ... 51 | +Transfer-Encoding: chunked 52 | +7;ext1=ext1_v 53 | +Hello, 54 | +6;ext2 55 | +world! 56 | +6 57 | +Again! 58 | +0 59 | +Expires: Wed, 21 Oct 2015 07:28:00 GMT 60 | +Header1: Header1 value text 61 | +Header2: Header2 value text 62 | + 63 | + 64 | - : unit = () 65 | ``` 66 | 67 | Writes only chunked body and not the trailers since `ua_supports_trailer:false`. 68 | 69 | ```ocaml 70 | # test_writer (Chunked.writable ~ua_supports_trailer:false write_chunk write_trailer) ;; 71 | +Resuming ... 72 | +Resuming ... 73 | +Transfer-Encoding: chunked 74 | +7;ext1=ext1_v 75 | +Hello, 76 | +6;ext2 77 | +world! 78 | +6 79 | +Again! 80 | +0 81 | + 82 | + 83 | - : unit = () 84 | ``` 85 | 86 | ## Chunked.reader 87 | 88 | ```ocaml 89 | let test_reader body headers f = 90 | Eio_main.run @@ fun env -> 91 | let buf_read = Eio.Buf_read.of_string body in 92 | let headers = Headers.of_list headers in 93 | let r = Body.make_readable headers buf_read in 94 | f r 95 | 96 | let f chunk = Eio.traceln "%a" Chunked.pp chunk 97 | 98 | let body = "7;ext1=ext1_v;ext2=ext2_v;ext3\r\nMozilla\r\n9\r\nDeveloper\r\n7\r\nNetwork\r\n0\r\nHeader2: Header2 value text\r\nHeader1: Header1 value text\r\nExpires: Wed, 21 Oct 2015 07:28:00 GMT\r\n\r\n" 99 | ``` 100 | 101 | The test below prints chunks to a standard output and returns trailer headers. Note, we don't return `Header2` 102 | because the `Trailer` header in request doesn't specify Header2 as being included in the chunked encoding trailer 103 | header list. 104 | 105 | ```ocaml 106 | # let headers = 107 | test_reader 108 | body 109 | ["Trailer", "Expires, Header1"; "Transfer-Encoding", "chunked"] 110 | (Chunked.read_chunked f);; 111 | + 112 | +[size = 7; ext1="ext1_v" ext2="ext2_v" ext3 113 | +Mozilla 114 | +] 115 | + 116 | +[size = 9 117 | +Developer 118 | +] 119 | + 120 | +[size = 7 121 | +Network 122 | +] 123 | + 124 | +[size = 0 ] 125 | val headers : Headers.t option = Some 126 | 127 | # Eio.traceln "%a" Headers.pp (Option.get headers) ;; 128 | +[ 129 | + Content-Length: 23; 130 | + Header1: Header1 value text 131 | +] 132 | - : unit = () 133 | ``` 134 | 135 | Returns `Header2` since it is specified in the request `Trailer` header. 136 | 137 | ```ocaml 138 | # let headers = 139 | test_reader 140 | body 141 | ["Trailer", "Expires, Header1, Header2"; "Transfer-Encoding", "chunked"] 142 | (Chunked.read_chunked f);; 143 | + 144 | +[size = 7; ext1="ext1_v" ext2="ext2_v" ext3 145 | +Mozilla 146 | +] 147 | + 148 | +[size = 9 149 | +Developer 150 | +] 151 | + 152 | +[size = 7 153 | +Network 154 | +] 155 | + 156 | +[size = 0 ] 157 | val headers : Headers.t option = Some 158 | 159 | # Eio.traceln "%a" Headers.pp (Option.get headers) ;; 160 | +[ 161 | + Content-Length: 23; 162 | + Header2: Header2 value text; 163 | + Header1: Header1 value text 164 | +] 165 | - : unit = () 166 | ``` 167 | 168 | Nothing is read if `Transfer-Encoding: chunked` header is missing. 169 | 170 | ```ocaml 171 | # let headers = 172 | test_reader 173 | body 174 | ["Trailer", "Expires, Header1, Header2"; "Transfer-Encoding", "gzip"] 175 | (Chunked.read_chunked f);; 176 | val headers : Headers.t option = None 177 | 178 | # headers = None;; 179 | - : bool = true 180 | ``` 181 | 182 | reader works okay even if there are no trailers. 183 | 184 | ```ocaml 185 | let body = "7;ext1=ext1_v;ext2=ext2_v;ext3\r\nMozilla\r\n9\r\nDeveloper\r\n7\r\nNetwork\r\n0\r\n\r\n" 186 | ``` 187 | 188 | ```ocaml 189 | # let headers = 190 | test_reader 191 | body 192 | ["Trailer", "Expires, Header1, Header2"; "Transfer-Encoding", "chunked"] 193 | (Chunked.read_chunked f);; 194 | + 195 | +[size = 7; ext1="ext1_v" ext2="ext2_v" ext3 196 | +Mozilla 197 | +] 198 | + 199 | +[size = 9 200 | +Developer 201 | +] 202 | + 203 | +[size = 7 204 | +Network 205 | +] 206 | + 207 | +[size = 0 ] 208 | val headers : Headers.t option = Some 209 | 210 | # headers = None;; 211 | - : bool = false 212 | ``` 213 | 214 | -------------------------------------------------------------------------------- /test/content_disposition.md: -------------------------------------------------------------------------------- 1 | # Content_disposition tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## Content_disposition.decode 8 | 9 | ```ocaml 10 | # let t = Content_disposition.decode "form-data; name=\"name\"; filename=\"New document 1.2020_08_01_13_16_42.0.svg\"" ;; 11 | val t : Content_disposition.t = 12 | 13 | # Content_disposition.disposition t ;; 14 | - : string = "form-data" 15 | 16 | # Content_disposition.find_param t "filename" ;; 17 | - : string option = Some "New document 1.2020_08_01_13_16_42.0.svg" 18 | 19 | # Content_disposition.find_param t "FILENAME" ;; 20 | - : string option = Some "New document 1.2020_08_01_13_16_42.0.svg" 21 | 22 | # Content_disposition.find_param t "name" ;; 23 | - : string option = Some "name" 24 | 25 | # Content_disposition.find_param t "param1" ;; 26 | - : string option = None 27 | ``` 28 | 29 | ## Content_disposition.make/encode 30 | 31 | ```ocaml 32 | # let t = Content_disposition.make ~params:[("filename", "\"hello world.png\""); ("name", "\"field1\"")] "form-data";; 33 | val t : Content_disposition.t = 34 | 35 | # Content_disposition.encode t ;; 36 | - : string = 37 | "form-data; filename=\"\"hello world.png\"\"; name=\"\"field1\"\"" 38 | ``` 39 | -------------------------------------------------------------------------------- /test/content_type.md: -------------------------------------------------------------------------------- 1 | # Content_type tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## Content_type.decode 8 | 9 | ```ocaml 10 | # let t = Content_type.decode "multipart/form-data; boundary=---------------------------735323031399963166993862150; charset=\"utf-8\"" ;; 11 | val t : Content_type.t = 12 | 13 | # Content_type.find_param t "boundary" ;; 14 | - : string option = 15 | Some "---------------------------735323031399963166993862150" 16 | 17 | # Content_type.find_param t "charset" ;; 18 | - : string option = Some "utf-8" 19 | 20 | # let t = Content_type.decode "multipart/form-data; boundary=---------------------------735323031399963166993862150; charset=utf-8" ;; 21 | val t : Content_type.t = 22 | 23 | # Content_type.find_param t "charset" ;; 24 | - : string option = Some "utf-8" 25 | ``` 26 | 27 | ## Content_type.media_type 28 | 29 | ```ocaml 30 | # Content_type.media_type t ;; 31 | - : Content_type.media_type = ("multipart", "form-data") 32 | ``` 33 | 34 | ## Content_type.charset 35 | 36 | ```ocaml 37 | # Content_type.charset t ;; 38 | - : string option = Some "utf-8" 39 | ``` 40 | 41 | ## Content_type.make/Content_type.find_param 42 | 43 | ```ocaml 44 | # let t = Content_type.make ~params:["charset","\"utf-8\""; "boundary", "------as28383ddd"] ("text", "plain");; 45 | val t : Content_type.t = 46 | 47 | # Content_type.charset t ;; 48 | - : string option = Some "\"utf-8\"" 49 | 50 | # Content_type.media_type t;; 51 | - : Content_type.media_type = ("text", "plain") 52 | 53 | # Content_type.find_param t "charset";; 54 | - : string option = Some "\"utf-8\"" 55 | 56 | # Content_type.find_param t "boundary";; 57 | - : string option = Some "------as28383ddd" 58 | ``` 59 | 60 | ## Content_type.encode 61 | 62 | ```ocaml 63 | # Content_type.encode t;; 64 | - : string = "text/plain; boundary=------as28383ddd; charset=\"utf-8\"" 65 | ``` 66 | -------------------------------------------------------------------------------- /test/cookie.md: -------------------------------------------------------------------------------- 1 | # Cookie tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ```ocaml 8 | let display_cookie name t = 9 | let pp_name_prefix = Fmt.(option ~none:(any "None") Cookie_name_prefix.pp) in 10 | Eio.traceln "Name: '%s'" name; 11 | Eio.traceln "NamePrefix: '%a'" pp_name_prefix @@ Cookie.name_prefix name t; 12 | Eio.traceln "Value : '%a'" Fmt.(option string) @@ Cookie.find_opt name t 13 | ``` 14 | 15 | ## decode 16 | 17 | ```ocaml 18 | # let t0 = Cookie.decode "SID=31d4d96e407aad42; lang=en";; 19 | val t0 : Cookie.t = 20 | 21 | # display_cookie "SID" t0;; 22 | +Name: 'SID' 23 | +NamePrefix: 'None' 24 | +Value : '31d4d96e407aad42' 25 | - : unit = () 26 | 27 | # display_cookie "lang" t0;; 28 | +Name: 'lang' 29 | +NamePrefix: 'None' 30 | +Value : 'en' 31 | - : unit = () 32 | ``` 33 | 34 | Decode should preserve double quotes in cookie value. 35 | 36 | ```ocaml 37 | # let t1 = Cookie.decode {|SID="31d4d96e407aad42"; lang="en"|};; 38 | val t1 : Cookie.t = 39 | 40 | # display_cookie "SID" t1;; 41 | +Name: 'SID' 42 | +NamePrefix: 'None' 43 | +Value : '"31d4d96e407aad42"' 44 | - : unit = () 45 | 46 | # display_cookie "lang" t1;; 47 | +Name: 'lang' 48 | +NamePrefix: 'None' 49 | +Value : '"en"' 50 | - : unit = () 51 | ``` 52 | 53 | Decode cookies with cookie name prefix. 54 | 55 | ```ocaml 56 | # display_cookie "SID" @@ Cookie.decode {|__Host-SID=1234|};; 57 | +Name: 'SID' 58 | +NamePrefix: '__Host-' 59 | +Value : '1234' 60 | - : unit = () 61 | 62 | # display_cookie "SID" @@ Cookie.decode {|__Secure-SID=1234|};; 63 | +Name: 'SID' 64 | +NamePrefix: '__Secure-' 65 | +Value : '1234' 66 | - : unit = () 67 | ``` 68 | 69 | 1. Cookie name prefixes are case-sensitive in Cookie header. (Set-Cookie decoding is case-insensitive.) 70 | 71 | ```ocaml 72 | # let t3 = Cookie.decode {|__SeCUre-SID=1234|};; 73 | val t3 : Cookie.t = 74 | 75 | # display_cookie "__SeCUre-SID" t3;; 76 | +Name: '__SeCUre-SID' 77 | +NamePrefix: 'None' 78 | +Value : '1234' 79 | - : unit = () 80 | 81 | # Cookie.find_opt "__SeCUre-SID" t3;; 82 | - : string option = Some "1234" 83 | ``` 84 | 85 | ```ocaml 86 | # Cookie.decode "";; 87 | Exception: Failure "take_while1". 88 | 89 | # Cookie.decode "a";; 90 | Exception: End_of_file. 91 | ``` 92 | 93 | ## is_empty 94 | 95 | ```ocaml 96 | # Cookie.(is_empty empty);; 97 | - : bool = true 98 | 99 | # Cookie.is_empty t0;; 100 | - : bool = false 101 | ``` 102 | 103 | ## Cookie.find_opt 104 | 105 | ```ocaml 106 | # Cookie.find_opt "SID" t0 ;; 107 | - : string option = Some "31d4d96e407aad42" 108 | 109 | # Cookie.find_opt "lang" t0 ;; 110 | - : string option = Some "en" 111 | 112 | # Cookie.find_opt "asdfsa" t0;; 113 | - : string option = None 114 | ``` 115 | 116 | ## Cookie.encode 117 | 118 | ```ocaml 119 | # Cookie.encode t0;; 120 | - : string = "SID=31d4d96e407aad42;lang=en" 121 | ``` 122 | 123 | Encode should preserve the double quotes in cookie value. 124 | 125 | ```ocaml 126 | # Cookie.encode t1;; 127 | - : string = "SID=\"31d4d96e407aad42\";lang=\"en\"" 128 | ``` 129 | 130 | Encode should add cookie name prefix if it exists. 131 | 132 | ```ocaml 133 | # Cookie.(add ~name_prefix:Cookie_name_prefix.host 134 | ~name:"SID" 135 | ~value:{|"1234"|} 136 | empty) 137 | |> Cookie.add ~name:"nm1" ~value:"3333" 138 | |> Cookie.encode;; 139 | - : string = "__Host-SID=\"1234\";nm1=3333" 140 | ``` 141 | 142 | ## Cookie.add 143 | 144 | ```ocaml 145 | # let t = Cookie.add ~name:"id" ~value:"value1" t0;; 146 | val t : Cookie.t = 147 | 148 | # Cookie.find_opt "id" t;; 149 | - : string option = Some "value1" 150 | 151 | # Cookie.encode t;; 152 | - : string = "SID=31d4d96e407aad42;id=value1;lang=en" 153 | ``` 154 | 155 | `name` parameter is validated. 156 | 157 | ```ocaml 158 | # Cookie.add ~name:"id 1" ~value:"123" t0;; 159 | Exception: 160 | Invalid_argument 161 | "[name] is invalid. Unexpected data after parsing (at offset 2)". 162 | ``` 163 | 164 | `value` parameter is validated. 165 | 166 | ```ocaml 167 | # Cookie.add ~name:"id" ~value:"23,ab" t0;; 168 | Exception: 169 | Invalid_argument 170 | "[value] is invalid. Unexpected data after parsing (at offset 2)". 171 | ``` 172 | 173 | ## Cookie.remove 174 | 175 | ```ocaml 176 | # let t = Cookie.remove ~name:"id" t;; 177 | val t : Cookie.t = 178 | 179 | # Cookie.find_opt "id" t;; 180 | - : string option = None 181 | ``` 182 | -------------------------------------------------------------------------------- /test/cookie_name_prefix.md: -------------------------------------------------------------------------------- 1 | # Cookie_name_prefix 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## host/secure/to_string/pp 8 | 9 | Display cookie name prefix values. 10 | 11 | ```ocaml 12 | # Cookie_name_prefix.(host |> to_string) ;; 13 | - : string = "__Host-" 14 | 15 | # Eio.traceln "%a" Cookie_name_prefix.pp Cookie_name_prefix.secure;; 16 | +__Secure- 17 | - : unit = () 18 | ``` 19 | 20 | ## equal/compare 21 | 22 | ```ocaml 23 | # Cookie_name_prefix.(equal host secure, compare host secure);; 24 | - : bool * int = (false, -1) 25 | 26 | # Cookie_name_prefix.(equal host host, compare host host);; 27 | - : bool * int = (true, 0) 28 | 29 | # Cookie_name_prefix.(equal secure secure, compare secure secure);; 30 | - : bool * int = (true, 0) 31 | ``` 32 | 33 | ## cut_prefix 34 | 35 | ```ocaml 36 | let display_cut_result ((name1,t1), (name2, t2)) = 37 | let pp = Fmt.(option ~none:(any "None") Cookie_name_prefix.pp) in 38 | Eio.traceln "(%s, %a) (%s, %a)" name1 pp t1 name2 pp t2 39 | ``` 40 | 41 | Case sensitive match is the default. 42 | 43 | ```ocaml 44 | # Cookie_name_prefix.( 45 | cut_prefix "__Host-SID", 46 | cut_prefix ~case_sensitive:true "__HoST-SID") 47 | |> display_cut_result ;; 48 | +(SID, __Host-) (__HoST-SID, None) 49 | - : unit = () 50 | 51 | # Cookie_name_prefix.( 52 | cut_prefix "__Secure-SID", 53 | cut_prefix ~case_sensitive:true "__SeCUre-SID") 54 | |> display_cut_result ;; 55 | +(SID, __Secure-) (__SeCUre-SID, None) 56 | - : unit = () 57 | ``` 58 | 59 | Case in-sensitive cut. 60 | 61 | ```ocaml 62 | # Cookie_name_prefix.( 63 | cut_prefix ~case_sensitive:false "__Host-SID", 64 | cut_prefix ~case_sensitive:false "__HOst-SID") 65 | |> display_cut_result ;; 66 | +(SID, __Host-) (SID, __Host-) 67 | - : unit = () 68 | 69 | # Cookie_name_prefix.( 70 | cut_prefix ~case_sensitive:false "__Secure-SID", 71 | cut_prefix ~case_sensitive:false "__SECuRe-SID") 72 | |> display_cut_result ;; 73 | +(SID, __Secure-) (SID, __Secure-) 74 | - : unit = () 75 | ``` 76 | 77 | Prefix not matched 78 | 79 | ```ocaml 80 | # Cookie_name_prefix.cut_prefix "__HelloSID";; 81 | - : string * Cookie_name_prefix.t option = ("__HelloSID", None) 82 | ``` 83 | -------------------------------------------------------------------------------- /test/csrf.md: -------------------------------------------------------------------------------- 1 | # Csrf tests 2 | 3 | ```ocaml 4 | open Spring 5 | 6 | let client_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, 8081) 7 | let key = Base64.(decode_exn ~pad:false "knFR+ybPVw/DJoOn+e6vpNNU2Ip2Z3fj1sXMgEyWYhA") 8 | let nonce = Cstruct.of_string "aaaaaaaaaaaa" 9 | 10 | let form_codec = Csrf.form_codec key 11 | let csrf_tok = Base64.(decode_exn ~pad:false "zaQgjF+KK0vSXlYUPhHTlLx/EY+LgpSgy7BxyAdW9n0") 12 | 13 | let session = Session.cookie_codec key 14 | let make_form_submission_request (client_req : Request.client Request.t) = 15 | let client_req = 16 | let token_name = Csrf.token_name form_codec in 17 | let data = Session.Data.(add token_name csrf_tok empty) in 18 | let data = Session.encode ~nonce data session in 19 | let cookie_name = Session.cookie_name session in 20 | Request.add_cookie ~name:cookie_name ~value:data client_req 21 | in 22 | let b = Buffer.create 10 in 23 | let s = Eio.Flow.buffer_sink b in 24 | Eio.Buf_write.with_flow s (fun bw -> Request.write_client_request client_req bw); 25 | let buf_read = Eio.Buf_read.of_string (Buffer.contents b) in 26 | Request.parse_server_request ~session client_addr buf_read 27 | 28 | let run_with_random_generator f = 29 | Eio_main.run @@ fun env -> 30 | Mirage_crypto_rng_eio.run (module Mirage_crypto_rng.Fortuna) env @@ fun () -> 31 | f () 32 | 33 | let pp_response r = 34 | Eio_main.run @@ fun env -> 35 | let b = Buffer.create 10 in 36 | let s = Eio.Flow.buffer_sink b in 37 | Eio.Buf_write.with_flow s (fun bw -> 38 | Response.write_server_response bw r; 39 | ); 40 | Eio.traceln "%s" (Buffer.contents b);; 41 | ``` 42 | 43 | ## Csrf.enable_protection/token 44 | 45 | ```ocaml 46 | # let req = Request.make_server_request ~resource:"/" Method.get client_addr (Eio.Buf_read.of_string "");; 47 | val req : Request.server Request.t = 48 | 49 | # run_with_random_generator @@ fun () -> Csrf.enable_protection req form_codec;; 50 | - : unit = () 51 | 52 | # Csrf.token req form_codec |> Option.is_some;; 53 | - : bool = true 54 | ``` 55 | 56 | ## Csrf.protect_request 57 | 58 | Return OK response if the CSRF token in form matches the one in session. 59 | 60 | ```ocaml 61 | let host = Host.decode "www.example.com" 62 | ``` 63 | 64 | ```ocaml 65 | # let csrf_form_req = 66 | Eio_main.run @@ fun _env -> 67 | let tok : string = Spring__Secret.encrypt_base64 nonce key csrf_tok in 68 | let token_name = Csrf.token_name form_codec in 69 | let body = 70 | Body.writable_form_values [token_name, tok; "name2", "val c"; "name2", "val d"; "name2", "val e"] 71 | in 72 | Request.make_client_request 73 | ~resource:"/post_form" 74 | host 75 | Method.post 76 | body 77 | |> make_form_submission_request ;; 78 | val csrf_form_req : Request.server Request.t = 79 | 80 | # let res = Csrf.protect_request form_codec csrf_form_req (fun _ -> Response.text "hello") ;; 81 | val res : Csrf.response = 82 | 83 | # pp_response res;; 84 | +HTTP/1.1 200 OK 85 | +Content-Length: 5 86 | +Content-Type: text/plain; charset=uf-8 87 | + 88 | +hello 89 | - : unit = () 90 | ``` 91 | 92 | Return `Bad Request` response if the CSRF tokens dont' match. 93 | 94 | ```ocaml 95 | # let csrf_form_req = 96 | Eio_main.run @@ fun _env -> 97 | let tok : string = Spring__Secret.encrypt_base64 nonce key "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" in 98 | let token_name = Csrf.token_name form_codec in 99 | let body = 100 | Body.writable_form_values [token_name, tok;"name2","val c"; "name2","val d"; "name2","val e"] 101 | in 102 | Request.make_client_request 103 | ~resource:"/post_form" 104 | host 105 | Method.post 106 | body 107 | |> make_form_submission_request ;; 108 | val csrf_form_req : Request.server Request.t = 109 | 110 | # let res = Csrf.protect_request form_codec csrf_form_req (fun _ -> Response.text "hello") ;; 111 | val res : Csrf.response = 112 | 113 | # pp_response res;; 114 | +HTTP/1.1 400 Bad Request 115 | +Content-Length: 0 116 | + 117 | + 118 | - : unit = () 119 | ``` 120 | 121 | Mulitpart/formdata form. 122 | 123 | ```ocaml 124 | # let p1 = 125 | let tok = Spring__Secret.encrypt_base64 nonce key csrf_tok in 126 | let token_name = Csrf.token_name form_codec in 127 | Multipart.writable_value_part ~form_name:token_name ~value:tok ;; 128 | val p1 : Multipart.writable Multipart.part = 129 | 130 | # let p2 = Multipart.writable_value_part ~form_name:"file1" ~value:"file is a text file." ;; 131 | val p2 : Multipart.writable Multipart.part = 132 | 133 | # let csrf_form_req = 134 | Eio_main.run @@ fun _env -> 135 | let form_body = Multipart.writable ~boundary:"--A1B2C3" [p1;p2] in 136 | Request.make_client_request 137 | ~resource:"/post_form" 138 | host 139 | Method.post 140 | form_body 141 | |> make_form_submission_request ;; 142 | val csrf_form_req : Request.server Request.t = 143 | 144 | # let res = Csrf.protect_request form_codec csrf_form_req (fun _ -> Response.text "hello") ;; 145 | val res : Csrf.response = 146 | 147 | # pp_response res;; 148 | +HTTP/1.1 200 OK 149 | +Content-Length: 5 150 | +Content-Type: text/plain; charset=uf-8 151 | + 152 | +hello 153 | - : unit = () 154 | ``` 155 | -------------------------------------------------------------------------------- /test/date.md: -------------------------------------------------------------------------------- 1 | # Date tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## Date.decode 8 | 9 | ```ocaml 10 | # let date1 = Date.decode "Sun, 06 Nov 1994 08:49:37 GMT";; 11 | val date1 : Date.t = 12 | 13 | # Eio.traceln "%a" Date.pp date1 ;; 14 | +Sun, 06 Nov 1994 08:49:37 GMT 15 | - : unit = () 16 | 17 | # let date2 = Date.decode "Sunday, 06-Nov-94 08:49:37 GMT";; 18 | val date2 : Date.t = 19 | 20 | # Eio.traceln "%a" Date.pp date2 ;; 21 | +Sun, 06 Nov 1994 08:49:37 GMT 22 | - : unit = () 23 | 24 | # let date3 = Date.decode "Sun Nov 6 08:49:37 1994";; 25 | val date3 : Date.t = 26 | 27 | # Eio.traceln "%a" Date.pp date3 ;; 28 | +Sun, 06 Nov 1994 08:49:37 GMT 29 | - : unit = () 30 | ``` 31 | 32 | ## Date.encode 33 | 34 | ```ocaml 35 | # Date.encode date1;; 36 | - : string = "Sun, 06 Nov 1994 08:49:37 GMT" 37 | 38 | # Date.encode date2;; 39 | - : string = "Sun, 06 Nov 1994 08:49:37 GMT" 40 | 41 | # Date.encode date3;; 42 | - : string = "Sun, 06 Nov 1994 08:49:37 GMT" 43 | ``` 44 | 45 | ## Date.now 46 | 47 | ```ocaml 48 | let now = 1666627935.85052109 49 | let mock_clock = Eio_mock.Clock.make () 50 | let () = Eio_mock.Clock.set_time mock_clock now 51 | ``` 52 | 53 | ```ocaml 54 | # let d1 = Date.now mock_clock;; 55 | val d1 : Date.t = 56 | 57 | # let d2 = Date.of_float_s now |> Option.get;; 58 | val d2 : Date.t = 59 | 60 | # Date.equal d1 d2;; 61 | - : bool = true 62 | ``` 63 | 64 | ## Date.of_ptime/of_float_s/equal/compare/is_later/is_earlier 65 | 66 | ```ocaml 67 | let now = 1623940778.27033591 68 | ``` 69 | 70 | `Date.t` created using same value `now` are equal. 71 | 72 | ```ocaml 73 | 74 | # let p = Ptime.of_float_s now |> Option.get;; 75 | val p : Ptime.t = 76 | 77 | # let d1 = Date.of_ptime p ;; 78 | val d1 : Date.t = 79 | 80 | # let d2 = Date.of_float_s now |> Option.get;; 81 | val d2 : Date.t = 82 | 83 | # Date.equal d1 d2;; 84 | - : bool = true 85 | 86 | # Date.compare d1 d2;; 87 | - : int = 0 88 | 89 | # Date.is_later d1 ~than:d2, Date.is_later d2 ~than:d1;; 90 | - : bool * bool = (false, false) 91 | 92 | # Date.is_earlier d1 ~than:d2, Date.is_earlier d2 ~than:d1;; 93 | - : bool * bool = (false, false) 94 | ``` 95 | 96 | `Date.t` created later returns `true` when comparing `is_later/is_earlier` with `d3`. 97 | 98 | ```ocaml 99 | # let d3 = Date.of_ptime @@ Ptime_clock.now ();; 100 | val d3 : Date.t = 101 | 102 | # Date.is_later d3 ~than:d1, Date.is_later d3 ~than:d2;; 103 | - : bool * bool = (true, true) 104 | 105 | # Date.is_earlier d1 ~than:d3, Date.is_earlier d1 ~than:d3;; 106 | - : bool * bool = (true, true) 107 | ``` 108 | 109 | ## equal 110 | 111 | Decoding a value, encoding and decoding it back. `Date.t` should be equal. 112 | 113 | ```ocaml 114 | # let v1 = "Thu, 17 Jun 2021 14:39:38 GMT";; 115 | val v1 : string = "Thu, 17 Jun 2021 14:39:38 GMT" 116 | 117 | # let dd1 = Date.decode v1;; 118 | val dd1 : Date.t = 119 | 120 | # let v2 = Date.encode dd1;; 121 | val v2 : string = "Thu, 17 Jun 2021 14:39:38 GMT" 122 | 123 | # String.equal v1 v2;; 124 | - : bool = true 125 | 126 | # let dd2 = Date.decode v2;; 127 | val dd2 : Date.t = 128 | 129 | # Date.equal dd1 dd2 130 | - : bool = true 131 | 132 | # Date.compare dd1 dd2;; 133 | - : int = 0 134 | ``` 135 | -------------------------------------------------------------------------------- /test/dune: -------------------------------------------------------------------------------- 1 | (copy_files certificates/*.pem) 2 | 3 | (copy_files certificates/*.key) 4 | 5 | (copy_files ../examples/hello/master.key) 6 | 7 | (mdx 8 | (package spring) 9 | (deps server.pem server.key) 10 | (libraries 11 | eio 12 | eio.core 13 | eio.unix 14 | eio.mock 15 | eio_main 16 | fmt 17 | cstruct 18 | ptime 19 | ptime.clock.os 20 | domain-name 21 | spring 22 | base64 23 | ohtml 24 | ipaddr 25 | tls 26 | tls-eio 27 | unix 28 | mirage-crypto 29 | mirage-crypto-rng 30 | mirage-crypto-rng-eio 31 | router_test)) 32 | 33 | (library 34 | (name router_test) 35 | (libraries spring) 36 | (modules router_test) 37 | (preprocess 38 | (pps spring))) 39 | -------------------------------------------------------------------------------- /test/etag.md: -------------------------------------------------------------------------------- 1 | # Etag tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | Strong ETag value. 8 | 9 | ```ocaml 10 | # let etag1 = Etag.decode {|"xyzzy"|};; 11 | val etag1 : Etag.t = 12 | 13 | # Etag.is_weak etag1;; 14 | - : bool = false 15 | 16 | # Etag.is_strong etag1;; 17 | - : bool = true 18 | 19 | # Etag.chars etag1;; 20 | - : string = "xyzzy" 21 | 22 | # Etag.encode etag1;; 23 | - : string = "\"xyzzy\"" 24 | ``` 25 | 26 | Weak ETag value. 27 | 28 | ```ocaml 29 | # let etag2 = Etag.decode {|W/"xyzzy"|};; 30 | val etag2 : Etag.t = 31 | 32 | # Etag.is_weak etag2;; 33 | - : bool = true 34 | 35 | # Etag.is_strong etag2;; 36 | - : bool = false 37 | 38 | # Etag.chars etag2;; 39 | - : string = "xyzzy" 40 | 41 | # Etag.encode etag2;; 42 | - : string = "W/\"xyzzy\"" 43 | ``` 44 | 45 | Decode empty string. 46 | 47 | ```ocaml 48 | # Etag.decode {|""|};; 49 | - : Etag.t = 50 | ``` 51 | 52 | Etag.equal. 53 | 54 | ```ocaml 55 | # Etag.strong_equal etag1 etag2;; 56 | - : bool = false 57 | 58 | # Etag.strong_equal etag2 etag1;; 59 | - : bool = false 60 | 61 | # Etag.strong_equal etag1 (Etag.make "xyzzy" );; 62 | - : bool = true 63 | 64 | # Etag.weak_equal etag1 etag2;; 65 | - : bool = true 66 | 67 | # Etag.weak_equal etag2 etag1;; 68 | - : bool = true 69 | ``` 70 | 71 | Invalid ETag value. 72 | 73 | ```ocaml 74 | # Etag.decode {|"adasdf"aa|};; 75 | Exception: Invalid_argument "[v] contains invalid ETag value". 76 | 77 | # Etag.decode {|"asdfasd "|} ;; 78 | Exception: Failure "Expected '\"' but got ' '". 79 | ``` 80 | -------------------------------------------------------------------------------- /test/expires.md: -------------------------------------------------------------------------------- 1 | # Expires tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## expired/is_expired/expired_value 8 | 9 | 1. An expired value returns `true` for `is_expired`. 10 | 2. Expires.expired value is encoded as `0`. 11 | 2. An expired value `ed` is any invalid HTTP Date.t value. 12 | 3. Two expired values with two different invalid HTTP Date.t values are equal. 13 | 14 | ```ocaml 15 | # Expires.(is_expired expired);; 16 | - : bool = true 17 | 18 | # Expires.(expired_value expired);; 19 | - : string option = Some "0" 20 | 21 | # let ed = Expires.decode "-1";; 22 | val ed : Expires.t = 23 | 24 | # Expires.is_expired ed;; 25 | - : bool = true 26 | 27 | # Expires.(equal ed expired);; 28 | - : bool = true 29 | ``` 30 | 31 | ## pp 32 | 33 | ```ocaml 34 | # Eio.traceln "%a" Expires.pp Expires.expired;; 35 | +0 36 | - : unit = () 37 | ``` 38 | 39 | ## of_date 40 | 41 | ```ocaml 42 | let now = 1623940778.27033591 43 | let clock = Eio_mock.Clock.make () 44 | let () = Eio_mock.Clock.set_time clock now 45 | ``` 46 | 47 | 1. Create a `now` Date.t value. 48 | 2. Create `e` as Expires.t from `now`. 49 | 3. Display `e` properties. 50 | 4. Encode `e` to `s1`. 51 | 5. Decode `s1` to `e2`. 52 | 6. `e` and `e2` is equal. 53 | 7. Expires.date `e` and `now` is equal since they are both the same Date.t value. 54 | 55 | ```ocaml 56 | # let now = Date.now clock ;; 57 | val now : Date.t = 58 | 59 | # let e = Expires.of_date now;; 60 | val e : Expires.t = 61 | 62 | # Expires.is_expired e;; 63 | - : bool = false 64 | 65 | # let s1 = Expires.encode e;; 66 | val s1 : string = "Thu, 17 Jun 2021 14:39:38 GMT" 67 | 68 | # let e2 = Expires.decode s1;; 69 | val e2 : Expires.t = 70 | 71 | # Expires.equal e e2;; 72 | - : bool = true 73 | 74 | # Expires.date e |> Option.get = now;; 75 | - : bool = true 76 | ``` 77 | 78 | ## decode/encode 79 | 80 | 1. Decode from `s1` to `e1`. 81 | 2. Encode `e1` to `s2`. 82 | 3. `s1` is equal to `s2`. 83 | 4. Decode `s2` to `e2`. 84 | 5. `e1` is equal to `e2`. 85 | 86 | ```ocaml 87 | # let s1 = "Thu, 17 Jun 2021 14:39:38 GMT";; 88 | val s1 : string = "Thu, 17 Jun 2021 14:39:38 GMT" 89 | 90 | # let e1 = Expires.decode s1;; 91 | val e1 : Expires.t = 92 | 93 | # let s2 = Expires.encode e1;; 94 | val s2 : string = "Thu, 17 Jun 2021 14:39:38 GMT" 95 | 96 | # String.equal s1 s2;; 97 | - : bool = true 98 | 99 | # let e2 = Expires.decode s2;; 100 | val e2 : Expires.t = 101 | 102 | # Expires.equal e1 e2;; 103 | - : bool = true 104 | ``` 105 | 106 | ## equal 107 | 108 | ```ocaml 109 | # Expires.equal e1 e2;; 110 | - : bool = true 111 | 112 | # Expires.equal e e;; 113 | - : bool = true 114 | 115 | # Expires.(equal expired expired);; 116 | - : bool = true 117 | 118 | # Expires.equal e e1;; 119 | - : bool = true 120 | ``` 121 | -------------------------------------------------------------------------------- /test/host.md: -------------------------------------------------------------------------------- 1 | # Host 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ```ocaml 8 | # #install_printer Host.pp;; 9 | ``` 10 | 11 | ## decode 12 | 13 | Decode IPv6 host and port. 14 | 15 | ```ocaml 16 | # let t0 = Host.decode "192.168.0.1:8080";; 17 | val t0 : Host.t = IPv4 192.168.0.1:8080 18 | ``` 19 | 20 | Decode IPv4 host only. 21 | 22 | ```ocaml 23 | # let t1 = Host.decode "192.168.0.1";; 24 | val t1 : Host.t = IPv4 192.168.0.1: 25 | ``` 26 | 27 | Decode domain name. 28 | 29 | ```ocaml 30 | # let t2 = Host.decode "www.example.com:8080";; 31 | val t2 : Host.t = Domain www.example.com:8080 32 | ``` 33 | 34 | Decode IPv6 host and port. 35 | 36 | ```ocaml 37 | # let t3 = Host.decode "[2001:db8:aaaa:bbbb:cccc:dddd:eeee:1]:8080";; 38 | val t3 : Host.t = IPv6 2001:db8:aaaa:bbbb:cccc:dddd:eeee:1:8080 39 | ``` 40 | 41 | ## encode 42 | 43 | ```ocaml 44 | # Host.encode t0;; 45 | - : string = "192.168.0.1:8080" 46 | 47 | # Host.encode t1;; 48 | - : string = "192.168.0.1" 49 | 50 | # Host.encode t2;; 51 | - : string = "www.example.com:8080" 52 | 53 | # Host.encode t3;; 54 | - : string = "2001:db8:aaaa:bbbb:cccc:dddd:eeee:1:8080" 55 | ``` 56 | 57 | ## equal 58 | 59 | ```ocaml 60 | # Host.equal t0 t1;; 61 | - : bool = false 62 | 63 | # Host.equal t0 t0;; 64 | - : bool = true 65 | 66 | # Host.equal t1 t1;; 67 | - : bool = true 68 | 69 | # Host.equal t2 t2;; 70 | - : bool = true 71 | 72 | # Host.equal t3 t3;; 73 | - : bool = true 74 | ``` 75 | 76 | ## compare 77 | 78 | ```ocaml 79 | # Host.compare t0 t0;; 80 | - : int = 0 81 | 82 | # Host.compare t0 t1;; 83 | - : int = 1 84 | 85 | # Host.compare t0 t2;; 86 | - : int = 1 87 | 88 | # Host.compare t1 t1;; 89 | - : int = 0 90 | 91 | # Host.compare t1 t0;; 92 | - : int = -1 93 | 94 | # Host.compare t1 t2;; 95 | - : int = 1 96 | 97 | # Host.compare t2 t2;; 98 | - : int = 0 99 | 100 | # Host.compare t2 t0;; 101 | - : int = -1 102 | 103 | # Host.compare t2 t1;; 104 | - : int = -1 105 | 106 | # Host.compare t3 t3;; 107 | - : int = 0 108 | 109 | # Host.compare t3 t0;; 110 | - : int = 1 111 | 112 | # Host.compare t3 t1;; 113 | - : int = 1 114 | 115 | # Host.compare t3 t2;; 116 | - : int = 1 117 | ``` 118 | 119 | ## pp 120 | 121 | ```ocaml 122 | # Eio.traceln "%a" Host.pp t0;; 123 | +IPv4 192.168.0.1:8080 124 | - : unit = () 125 | 126 | # Eio.traceln "%a" Host.pp t1;; 127 | +IPv4 192.168.0.1: 128 | - : unit = () 129 | 130 | # Eio.traceln "%a" Host.pp t2;; 131 | +Domain www.example.com:8080 132 | - : unit = () 133 | 134 | # Eio.traceln "%a" Host.pp t3;; 135 | +IPv6 2001:db8:aaaa:bbbb:cccc:dddd:eeee:1:8080 136 | - : unit = () 137 | ``` 138 | -------------------------------------------------------------------------------- /test/if_none_match.md: -------------------------------------------------------------------------------- 1 | # If_none_match tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | Any value. 8 | 9 | ```ocaml 10 | # let any = If_none_match.any;; 11 | val any : If_none_match.t = 12 | 13 | # If_none_match.is_any any;; 14 | - : bool = true 15 | ``` 16 | 17 | ## make/contains_entity_tag. 18 | 19 | ```ocaml 20 | # let etag1 =Etag.make "xyzzy" and etag2 = Etag.make ~weak:true "xyzzy" ;; 21 | val etag1 : Etag.t = 22 | val etag2 : Etag.t = 23 | 24 | # let etags = [etag1; etag2] ;; 25 | val etags : Etag.t list = [ ; ] 26 | 27 | # let t = If_none_match.make etags;; 28 | val t : If_none_match.t = 29 | 30 | # If_none_match.contains_entity_tag (fun etag -> Etag.weak_equal etag etag2) t ;; 31 | - : bool = true 32 | 33 | # If_none_match.contains_entity_tag (fun etag -> Etag.strong_equal etag etag2) t ;; 34 | - : bool = false 35 | 36 | # If_none_match.contains_entity_tag (fun etag -> Etag.strong_equal etag etag1) t ;; 37 | - : bool = true 38 | ``` 39 | 40 | Searching for entity tag in [any t = true] is always true. 41 | 42 | ```ocaml 43 | # If_none_match.contains_entity_tag (fun _ -> false) any;; 44 | - : bool = true 45 | ``` 46 | 47 | Empty entity_tags is invalild. 48 | 49 | ```ocaml 50 | # If_none_match.make [];; 51 | Exception: Invalid_argument "[entity_tags] is empty". 52 | ``` 53 | 54 | ## entity_tags 55 | 56 | Retrieve entity tags. 57 | 58 | ```ocaml 59 | # If_none_match.entity_tags t = Some etags;; 60 | - : bool = true 61 | ``` 62 | 63 | ## decode 64 | 65 | Decode a strong etag value. 66 | 67 | ```ocaml 68 | # let t1 = If_none_match.decode {|"c3piozzzz"|};; 69 | val t1 : If_none_match.t = 70 | 71 | # If_none_match.entity_tags t1 72 | |> Option.get 73 | |> List.iter (fun etag -> Eio.traceln "%s" (Etag.encode etag)) ;; 74 | +"c3piozzzz" 75 | - : unit = () 76 | ``` 77 | 78 | Decode a weak etag value. 79 | 80 | ```ocaml 81 | # let t2 = If_none_match.decode {|W/"xyzzy"|};; 82 | val t2 : If_none_match.t = 83 | 84 | # If_none_match.entity_tags t2 85 | |> Option.get 86 | |> List.iter (fun etag -> Eio.traceln "%s" (Etag.encode etag)) ;; 87 | +W/"xyzzy" 88 | - : unit = () 89 | ``` 90 | 91 | Decode a list of strong etag values. 92 | 93 | ```ocaml 94 | # let t3 = If_none_match.decode {|"xyzzy", "r2d2xxxx", "c3piozzzz"|};; 95 | val t3 : If_none_match.t = 96 | 97 | # If_none_match.entity_tags t3 98 | |> Option.get 99 | |> List.iter (fun etag -> Eio.traceln "%s" (Etag.encode etag)) ;; 100 | +"xyzzy" 101 | +"r2d2xxxx" 102 | +"c3piozzzz" 103 | - : unit = () 104 | ``` 105 | 106 | Decode a list of weak etag values. 107 | 108 | ```ocaml 109 | # let t4 = If_none_match.decode {|W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"|};; 110 | val t4 : If_none_match.t = 111 | 112 | # If_none_match.entity_tags t4 113 | |> Option.get 114 | |> List.iter (fun etag -> Eio.traceln "%s" (Etag.encode etag)) ;; 115 | +W/"xyzzy" 116 | +W/"r2d2xxxx" 117 | +W/"c3piozzzz" 118 | - : unit = () 119 | ``` 120 | 121 | Decode a list of weak and strong etag values. 122 | 123 | ```ocaml 124 | # let t5 = If_none_match.decode {|"xyzzy", W/"r2d2xxxx", "c3piozzz", W/"c3piozzzz"|};; 125 | val t5 : If_none_match.t = 126 | 127 | # If_none_match.entity_tags t5 128 | |> Option.get 129 | |> List.iter (fun etag -> Eio.traceln "%s" (Etag.encode etag)) ;; 130 | +"xyzzy" 131 | +W/"r2d2xxxx" 132 | +"c3piozzz" 133 | +W/"c3piozzzz" 134 | - : unit = () 135 | ``` 136 | 137 | Decode '*'. 138 | 139 | ```ocaml 140 | # let any1 = If_none_match.decode "*";; 141 | val any1 : If_none_match.t = 142 | 143 | # If_none_match.is_any any1;; 144 | - : bool = true 145 | ``` 146 | 147 | Invalid values. 148 | 149 | ```ocaml 150 | # If_none_match.decode "**";; 151 | Exception: Invalid_argument "[s] contains invalid [If-None-Match] value". 152 | 153 | # If_none_match.decode {| "xyzzy",|};; 154 | Exception: Invalid_argument "[v] contains invalid ETag value". 155 | ``` 156 | 157 | ## encode 158 | 159 | ```ocaml 160 | # If_none_match.encode any;; 161 | - : string = "*" 162 | 163 | # If_none_match.encode any1;; 164 | - : string = "*" 165 | 166 | # If_none_match.encode t1;; 167 | - : string = "\"c3piozzzz\"" 168 | 169 | # If_none_match.encode t2;; 170 | - : string = "W/\"xyzzy\"" 171 | 172 | # If_none_match.encode t3;; 173 | - : string = "\"xyzzy\", \"r2d2xxxx\", \"c3piozzzz\"" 174 | 175 | # If_none_match.encode t4;; 176 | - : string = "W/\"xyzzy\", W/\"r2d2xxxx\", W/\"c3piozzzz\"" 177 | 178 | # If_none_match.encode t5;; 179 | - : string = "\"xyzzy\", W/\"r2d2xxxx\", \"c3piozzz\", W/\"c3piozzzz\"" 180 | ``` 181 | -------------------------------------------------------------------------------- /test/method.md: -------------------------------------------------------------------------------- 1 | ## Method tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ### Method.equal 8 | 9 | ```ocaml 10 | # Method.(equal get get) ;; 11 | - : bool = true 12 | 13 | # Method.(equal get post) ;; 14 | - : bool = false 15 | ``` 16 | 17 | ### Method.make 18 | 19 | ```ocaml 20 | # let lock = Method.make "lock" ;; 21 | val lock : Method.t = "lock" 22 | 23 | # let a = Method.make "get" ;; 24 | val a : Method.t = "get" 25 | 26 | # Method.(equal a get);; 27 | - : bool = true 28 | ``` 29 | 30 | ## Method.to_string 31 | 32 | ```ocaml 33 | # let m = Method.(to_string get) ;; 34 | val m : Method.t = "get" 35 | 36 | # String.equal "get" (m :> string) ;; 37 | - : bool = true 38 | ``` 39 | -------------------------------------------------------------------------------- /test/multipart.md: -------------------------------------------------------------------------------- 1 | # Multipart tests 2 | 3 | ```ocaml 4 | open Spring 5 | 6 | let body content_type_hdr txt = 7 | let headers = Headers.of_list ["content-type", content_type_hdr] in 8 | let buf_read = Eio.Buf_read.of_string txt in 9 | Body.make_readable headers buf_read 10 | ;; 11 | 12 | let body_txt1 ="--AaB03x\r\nContent-Disposition: form-data; name=\"submit-name\"\r\n\r\nLarry\r\n--AaB03x\r\nContent-Disposition: form-data; name=\"files\"; filename=\"file1.txt\"\r\nContent-Type: text/plain\r\n\r\n... contents of file1.txt ...\r\n--AaB03x--" 13 | ``` 14 | 15 | ## Multipart.stream 16 | 17 | ```ocaml 18 | # let rdr = Multipart.stream (body "multipart/form-data" body_txt1);; 19 | Exception: Invalid_argument "body: boundary value not found". 20 | 21 | # let rdr = Multipart.stream (body "multipart/form-data; boundary=AaB03x" body_txt1);; 22 | val rdr : Multipart.stream = 23 | ``` 24 | 25 | ## Multipart.boundary 26 | 27 | ```ocaml 28 | # Multipart.boundary rdr;; 29 | - : string = "AaB03x" 30 | ``` 31 | 32 | ## Multipart.next_part/read_all 33 | 34 | ```ocaml 35 | # let p = Multipart.next_part rdr;; 36 | val p : Multipart.stream Multipart.part = 37 | 38 | # Multipart.file_name p ;; 39 | - : string option = None 40 | 41 | # Multipart.form_name p ;; 42 | - : string = "submit-name" 43 | 44 | # Multipart.headers p |> (Eio.traceln "%a" Headers.pp) ;; 45 | +[ 46 | + Content-Disposition: form-data; name="submit-name" 47 | +] 48 | - : unit = () 49 | 50 | # Multipart.read_all p;; 51 | - : string = "Larry" 52 | 53 | # Eio.Flow.single_read (Multipart.as_flow p) (Cstruct.create 10) ;; 54 | Exception: End_of_file. 55 | 56 | # let p2 = Multipart.next_part rdr;; 57 | val p2 : Multipart.stream Multipart.part = 58 | 59 | # Multipart.file_name p2;; 60 | - : string option = Some "file1.txt" 61 | 62 | # Multipart.form_name p2;; 63 | - : string = "files" 64 | 65 | # Multipart.read_all p2;; 66 | - : string = "... contents of file1.txt ..." 67 | 68 | # Multipart.read_all p2;; 69 | - : string = "" 70 | 71 | # Eio.Flow.single_read (Multipart.as_flow p2) (Cstruct.create 10) ;; 72 | Exception: End_of_file. 73 | 74 | # Multipart.next_part rdr;; 75 | Exception: End_of_file. 76 | ``` 77 | 78 | ## Multipart.form 79 | 80 | ```ocaml 81 | # let form = Multipart.form (body "multipart/form-data; boundary=AaB03x" body_txt1);; 82 | val form : Multipart.form = 83 | 84 | # Multipart.find_value_field "submit-name" form ;; 85 | - : string option = Some "Larry" 86 | 87 | # let form_field1 = Multipart.find_file_field "files" form |> Option.get ;; 88 | val form_field1 : Multipart.file_field = 89 | 90 | # Multipart.file_name form_field1 ;; 91 | - : string option = Some "file1.txt" 92 | 93 | # Multipart.file_content form_field1;; 94 | - : string = "... contents of file1.txt ..." 95 | 96 | # Eio.traceln "%a" Headers.pp @@ Multipart.headers form_field1;; 97 | +[ 98 | + Content-Disposition: form-data; name="files"; filename="file1.txt"; 99 | + Content-Type: text/plain 100 | +] 101 | - : unit = () 102 | ``` 103 | 104 | ## Multipart.writable 105 | 106 | A `Buffer.t` sink to test `Body.writer`. 107 | 108 | ```ocaml 109 | let test_writable f = 110 | Eio_main.run @@ fun env -> 111 | let b = Buffer.create 10 in 112 | let s = Eio.Flow.buffer_sink b in 113 | let body = f () in 114 | Eio.Buf_write.with_flow s (fun bw -> 115 | Body.write_headers bw body; 116 | Eio.Buf_write.string bw "\r\n"; 117 | Body.write_body bw body; 118 | ); 119 | Eio.traceln "%s" (Buffer.contents b);; 120 | ``` 121 | 122 | Writable with 2 parts. 123 | 124 | ```ocaml 125 | # let p1 = Multipart.writable_file_part ~filename:"a.txt" ~form_name:"file" (Eio.Flow.string_source "content of a.txt");; 126 | val p1 : Multipart.writable Multipart.part = 127 | 128 | # let p2 = Multipart.writable_value_part ~form_name:"detail" ~value:"file is a text file.";; 129 | val p2 : Multipart.writable Multipart.part = 130 | 131 | # test_writable @@ fun () -> Multipart.writable ~boundary:"--A1B2C3" [p1;p2];; 132 | +Content-Length: 190 133 | +Content-Type: multipart/formdata; boundary=--A1B2C3 134 | + 135 | +----A1B2C3 136 | +Content-Disposition: form-data; filename="a.txt"; name="file" 137 | + 138 | +content of a.txt 139 | +----A1B2C3 140 | +Content-Disposition: form-data; name="detail" 141 | + 142 | +file is a text file. 143 | +----A1B2C3-- 144 | - : unit = () 145 | ``` 146 | 147 | Writable with only one part. 148 | 149 | ```ocaml 150 | # let p1 = Multipart.writable_file_part ~filename:"a.txt" ~form_name:"file" (Eio.Flow.string_source "content of a.txt");; 151 | val p1 : Multipart.writable Multipart.part = 152 | 153 | # test_writable @@ fun () -> Multipart.writable ~boundary:"--A1B2C3" [p1];; 154 | +Content-Length: 107 155 | +Content-Type: multipart/formdata; boundary=--A1B2C3 156 | + 157 | +----A1B2C3 158 | +Content-Disposition: form-data; filename="a.txt"; name="file" 159 | + 160 | +content of a.txt 161 | +----A1B2C3-- 162 | - : unit = () 163 | ``` 164 | -------------------------------------------------------------------------------- /test/response.md: -------------------------------------------------------------------------------- 1 | # Response 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## Response.parse_client_response 8 | 9 | ```ocaml 10 | let make_buf_read () = 11 | Eio.Buf_read.of_string @@ 12 | "HTTP/1.1 200 OK\r\n" ^ 13 | "content-length: 13\r\n" ^ 14 | "date: Wed, 08 Feb 2023 16:18:17 GMT\r\n" ^ 15 | "content-type: text/html; charset=utf-8\r\n" ^ 16 | "x-powered-by: Express\r\n" ^ 17 | "cache-control: public, max-age=86400\r\n" ^ 18 | "cf-cache-status: DYNAMIC\r\n" ^ 19 | "server: cloudflare\r\n" ^ 20 | "cf-ray: 7965ae27fa7c75bf-LHR\r\n" ^ 21 | "content-encoding: br\r\n" ^ 22 | "X-Firefox-Spdy: h2\r\n" ^ 23 | "\r\n" ^ 24 | "hello, world!" 25 | ;; 26 | ``` 27 | 28 | ```ocaml 29 | # let res = Response.parse_client_response @@ make_buf_read () ;; 30 | val res : Response.client Response.t = 31 | 32 | # Eio.traceln "%a" Headers.pp @@ Response.headers res ;; 33 | +[ 34 | + Content-Length: 13; 35 | + Date: Wed, 08 Feb 2023 16:18:17 GMT; 36 | + Content-Type: text/html; charset=utf-8; 37 | + X-Powered-By: Express; 38 | + Cache-Control: public, max-age=86400; 39 | + Cf-Cache-Status: DYNAMIC; 40 | + Server: cloudflare; 41 | + Cf-Ray: 7965ae27fa7c75bf-LHR; 42 | + Content-Encoding: br; 43 | + X-Firefox-Spdy: h2 44 | +] 45 | - : unit = () 46 | ``` 47 | 48 | ## server_response 49 | 50 | A `Buffer.t` sink to test `Body.writer`. 51 | 52 | ```ocaml 53 | let test_server_response r = 54 | Eio_main.run @@ fun env -> 55 | let b = Buffer.create 10 in 56 | let s = Eio.Flow.buffer_sink b in 57 | Eio.Buf_write.with_flow s (fun bw -> 58 | Response.write_server_response bw r; 59 | ); 60 | Eio.traceln "%s" (Buffer.contents b);; 61 | ``` 62 | 63 | ## Response.text 64 | 65 | ```ocaml 66 | # test_server_response @@ Response.text "hello, world";; 67 | +HTTP/1.1 200 OK 68 | +Content-Length: 12 69 | +Content-Type: text/plain; charset=uf-8 70 | + 71 | +hello, world 72 | - : unit = () 73 | ``` 74 | 75 | ## Response.html 76 | 77 | ```ocaml 78 | # test_server_response @@ Response.html "hello, world";; 79 | +HTTP/1.1 200 OK 80 | +Content-Length: 12 81 | +Content-Type: text/html; charset=uf-8 82 | + 83 | +hello, world 84 | - : unit = () 85 | ``` 86 | 87 | ## Response.not_found 88 | 89 | ```ocaml 90 | # test_server_response @@ Response.not_found ;; 91 | +HTTP/1.1 404 Not Found 92 | +Content-Length: 0 93 | + 94 | + 95 | - : unit = () 96 | ``` 97 | 98 | ## Response.internal_server_error 99 | 100 | ```ocaml 101 | # test_server_response @@ Response.internal_server_error ;; 102 | +HTTP/1.1 500 Internal Server Error 103 | +Content-Length: 0 104 | + 105 | + 106 | - : unit = () 107 | ``` 108 | 109 | ## Response.bad_request 110 | 111 | ```ocaml 112 | # test_server_response @@ Response.bad_request ;; 113 | +HTTP/1.1 400 Bad Request 114 | +Content-Length: 0 115 | + 116 | + 117 | - : unit = () 118 | ``` 119 | 120 | ## Response.chunked_response 121 | 122 | ```ocaml 123 | # let write_chunk f = 124 | f @@ Chunked.make ~extensions:["ext1",Some "ext1_v"] "Hello, "; 125 | f @@ Chunked.make ~extensions:["ext2",None] "world!"; 126 | f @@ Chunked.make "Again!"; 127 | f @@ Chunked.make "";; 128 | val write_chunk : (Chunked.t -> 'a) -> 'a = 129 | 130 | # let write_trailer f = 131 | let trailer_headers = 132 | Headers.of_list 133 | [ 134 | ("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); 135 | ("Header1", "Header1 value text"); 136 | ("Header2", "Header2 value text"); 137 | ] 138 | in 139 | f trailer_headers;; 140 | val write_trailer : (Headers.t -> 'a) -> 'a = 141 | ``` 142 | 143 | Writes chunked response trailer headers. 144 | 145 | ```ocaml 146 | # test_server_response @@ Response.chunked_response ~ua_supports_trailer:true write_chunk write_trailer ;; 147 | +HTTP/1.1 200 OK 148 | +Transfer-Encoding: chunked 149 | + 150 | +7;ext1=ext1_v 151 | +Hello, 152 | +6;ext2 153 | +world! 154 | +6 155 | +Again! 156 | +0 157 | +Expires: Wed, 21 Oct 2015 07:28:00 GMT 158 | +Header1: Header1 value text 159 | +Header2: Header2 value text 160 | + 161 | + 162 | - : unit = () 163 | ``` 164 | 165 | No chunked trailer headers. 166 | 167 | ```ocaml 168 | # test_server_response @@ Response.chunked_response ~ua_supports_trailer:false write_chunk write_trailer ;; 169 | +HTTP/1.1 200 OK 170 | +Transfer-Encoding: chunked 171 | + 172 | +7;ext1=ext1_v 173 | +Hello, 174 | +6;ext2 175 | +world! 176 | +6 177 | +Again! 178 | +0 179 | + 180 | + 181 | - : unit = () 182 | ``` 183 | 184 | ## Response.add_set_cookie 185 | 186 | ```ocaml 187 | # let txt_response = Response.html "hello, world" ;; 188 | val txt_response : Response.server Response.t = 189 | ``` 190 | 191 | ```ocaml 192 | # let id_cookie = 193 | Set_cookie.make ~name:"ID" "1234" 194 | |> Set_cookie.(add secure) 195 | |> Set_cookie.(add http_only);; 196 | val id_cookie : Set_cookie.t = 197 | 198 | # let res = Response.add_set_cookie id_cookie txt_response ;; 199 | val res : Response.server Response.t = 200 | 201 | # test_server_response res;; 202 | +HTTP/1.1 200 OK 203 | +Content-Length: 12 204 | +Content-Type: text/html; charset=uf-8 205 | +Set-Cookie: ID=1234; Httponly; Secure 206 | + 207 | +hello, world 208 | - : unit = () 209 | ``` 210 | 211 | ## Response.find_set_cookie 212 | 213 | ```ocaml 214 | # Response.find_set_cookie "ID" res |> Option.iter (Eio.traceln "%a" Set_cookie.pp) ;; 215 | +{ 216 | + Name : 'ID' ; 217 | + Value : '1234' ; 218 | + Httponly ; 219 | + Secure ; 220 | +} 221 | - : unit = () 222 | ``` 223 | 224 | ## Response.remove_set_cookie 225 | 226 | ```ocaml 227 | # let res = Response.remove_set_cookie "ID" res;; 228 | val res : Response.server Response.t = 229 | 230 | # Response.find_set_cookie "ID" res ;; 231 | - : Set_cookie.t option = None 232 | ``` 233 | -------------------------------------------------------------------------------- /test/router.md: -------------------------------------------------------------------------------- 1 | # Router tests 2 | 3 | ```ocaml 4 | open Router_test 5 | open Spring 6 | 7 | let () = Printexc.record_backtrace true 8 | let test_get uri = Router.match' (make_request Method.get uri) router 9 | let test_head uri = Router.match' (make_request Method.head uri) router 10 | let test_post uri = Router.match' (make_request Method.post uri) router 11 | let test_delete uri = Router.match' (make_request Method.delete uri) router 12 | 13 | let fmt = Format.std_formatter 14 | ``` 15 | 16 | ```ocaml 17 | # test_get "/public/css/style.css";; 18 | - : string option = Some "file path: css/style.css" 19 | 20 | # test_get "/public/js/prog.js";; 21 | - : string option = Some "file path: js/prog.js" 22 | 23 | # test_get "/public/images/image1.jpg";; 24 | - : string option = Some "file path: images/image1.jpg" 25 | 26 | # test_get "/public/";; 27 | - : string option = Some "file path: " 28 | 29 | # test_get "/home/100001.1/"; 30 | - : string option = Some "Float page. number : 100001.100000" 31 | 32 | # test_post "/home/100001.1";; 33 | - : string option = None 34 | 35 | # test_head "/home/100001/";; 36 | - : string option = Some "Product Page. Product Id : 100001" 37 | 38 | # test_post "/home/about";; 39 | - : string option = None 40 | 41 | # test_get "/home/about/1";; 42 | - : string option = Some "about_page - 1" 43 | 44 | # test_post "/home/about/3";; 45 | - : string option = Some "about_page - 3" 46 | 47 | # test_head "/home/about/3";; 48 | - : string option = None 49 | 50 | # test_delete "/home/about/3";; 51 | - : string option = None 52 | 53 | # test_get "/contact/bikal/123456";; 54 | - : string option = Some "Contact page. Hi, bikal. Number 123456" 55 | 56 | # test_post "/home/products/asdfasdf?a=1&b=2";; 57 | - : string option = Some "full rest page: asdfasdf?a=1&b=2" 58 | 59 | # test_post "/home/products/product1/locate";; 60 | - : string option = Some "full rest page: product1/locate" 61 | 62 | # test_get "/home/product1/";; 63 | - : string option = Some "Wildcard page. product1. Remaining url: " 64 | 65 | # test_get "/contact/bikal/true";; 66 | - : string option = Some "Contact Page2. Name - bikal, number - true" 67 | 68 | # test_get "/contact/bob/false";; 69 | - : string option = Some "Contact Page2. Name - bob, number - false" 70 | 71 | # test_post "/product/dyson350?section=233&q=true";; 72 | - : string option = 73 | Some "Product detail - dyson350. Section: 233. Display questions? true" 74 | 75 | # test_post "/product/dyson350?section=2&q=false";; 76 | - : string option = 77 | Some "Product detail - dyson350. Section: 2. Display questions? false" 78 | 79 | # test_get "/product/dyson350?section=2&q1=no";; 80 | - : string option = None 81 | 82 | # test_get "/product/dyson350?section=2&q1=yes";; 83 | - : string option = Some "Product detail 2 - dyson350. Section: 2." 84 | 85 | # test_get "/product/dyson350/section/2/q1/yes";; 86 | - : string option = None 87 | 88 | # test_get "/fruit/apple";; 89 | - : string option = Some "Apples are juicy!" 90 | 91 | # test_get "/fruit/pineapple";; 92 | - : string option = Some "Pineapple has scaly skin" 93 | 94 | # test_get "/fruit/orange";; 95 | - : string option = Some "Orange is a citrus fruit." 96 | 97 | # test_get "/fruit/guava";; 98 | - : string option = None 99 | 100 | # test_get "/"; 101 | - : string option = Some "Root page" 102 | 103 | # test_head "/numbers/23/code/6888/";; 104 | - : string option = Some "int32: 23, int64: 6888." 105 | 106 | # test_head "/numbers/23.01/code/6888/";; 107 | - : string option = None 108 | 109 | # test_head "/numbers/23/code/6888.222/";; 110 | - : string option = None 111 | ``` 112 | 113 | ## Router.pp_route 114 | 115 | ```ocaml 116 | # Router.pp_route fmt route1;; 117 | GET/home/about/:bool?h=:int&b=:bool&e=hello 118 | - : unit = () 119 | 120 | # Router.pp_route fmt route2;; 121 | POST/home/about/:int/:string/:Fruit 122 | - : unit = () 123 | 124 | # Router.pp_route fmt route3;; 125 | HEAD/home/:int/:int32/:int64/:Fruit?q1=hello&f=:Fruit&b=:bool&f=:float 126 | - : unit = () 127 | ``` 128 | 129 | ## Router.pp 130 | 131 | ```ocaml 132 | # Format.fprintf fmt "%a%!" Router.pp router;; 133 | GET 134 | /home 135 | /about 136 | /:int 137 | /:float 138 | / 139 | /:string 140 | /** 141 | /contact 142 | /:string 143 | /:int 144 | /:bool 145 | /product 146 | /:string 147 | ?section=:int 148 | &q1=yes 149 | ?section=:string 150 | &q1=yes 151 | /fruit 152 | /:Fruit 153 | / 154 | /public 155 | /** 156 | POST 157 | /home 158 | /about 159 | /:int 160 | /products 161 | /** 162 | /product 163 | /:string 164 | ?section=:int 165 | &q=:bool 166 | HEAD 167 | /home 168 | /:int 169 | / 170 | /numbers 171 | /:int32 172 | /code 173 | /:int64 174 | / 175 | DELETE 176 | /home 177 | /:int 178 | / 179 | - : unit = () 180 | ``` 181 | 182 | ## Router.match' - match the top 1 first if more than one route is matched 183 | 184 | ```ocaml 185 | # Router_test.top_1_first () ;; 186 | - : string option = Some "Float: 12.000000" 187 | 188 | # Router_test.top_1_first_2 ();; 189 | - : string option = Some "Int : 12" 190 | ``` 191 | 192 | ## Router.match' - longest match wins if more than one route is matched 193 | 194 | ```ocaml 195 | # Router_test.longest_match ();; 196 | - : string option = Some "longest: 12" 197 | ``` 198 | -------------------------------------------------------------------------------- /test/router_test.ml: -------------------------------------------------------------------------------- 1 | open Spring 2 | 3 | module Fruit = struct 4 | type t = 5 | | Apple 6 | | Orange 7 | | Pineapple 8 | 9 | let t : t Router.arg = 10 | Router.make_arg "Fruit" (function 11 | | "apple" -> Some Apple 12 | | "orange" -> Some Orange 13 | | "pineapple" -> Some Pineapple 14 | | _ -> None) 15 | end 16 | 17 | type request = Request.server Request.t 18 | 19 | let fruit_page fruit (_req : request) = 20 | match fruit with 21 | | Fruit.Apple -> Printf.sprintf "Apples are juicy!" 22 | | Orange -> Printf.sprintf "Orange is a citrus fruit." 23 | | Pineapple -> Printf.sprintf "Pineapple has scaly skin" 24 | 25 | let about_page i (_req : request) = Format.sprintf "about_page - %d" i 26 | 27 | let full_rest_page url _req = Format.sprintf "full rest page: %s" url 28 | 29 | let home_int_page i (_req : request) = 30 | Printf.sprintf "Product Page. Product Id : %d" i 31 | 32 | let home_float_page f _req = Printf.sprintf "Float page. number : %f" f 33 | 34 | let wildcard_page s url _req = 35 | Printf.sprintf "Wildcard page. %s. Remaining url: %s" s url 36 | 37 | let numbers_page id code _req = Printf.sprintf "int32: %ld, int64: %Ld." id code 38 | 39 | let root_page (_req : request) = "Root page" 40 | 41 | let contact_page name number _req = 42 | Printf.sprintf "Contact page. Hi, %s. Number %i" name number 43 | 44 | let contact_page2 name call_me_later _req = 45 | Printf.sprintf "Contact Page2. Name - %s, number - %b" name call_me_later 46 | 47 | let product_page name section_id q _req = 48 | Printf.sprintf "Product detail - %s. Section: %d. Display questions? %b" name 49 | section_id q 50 | 51 | let product_page2 name section_id _req = 52 | Printf.sprintf "Product detail 2 - %s. Section: %d." name section_id 53 | 54 | let product_page3 name section_id _req = 55 | Printf.sprintf "Product detail 2 - %s. Section: %s." name section_id 56 | 57 | let public url _req = Format.sprintf "file path: %s" url 58 | 59 | let router = 60 | Router.( 61 | make 62 | [ route Method.get [%r "/home/about/:int"] about_page 63 | ; route Method.post [%r "/home/about/:int"] about_page 64 | ; route Method.head [%r "/home/:int/"] home_int_page 65 | ; route Method.delete [%r "/home/:int/"] home_int_page 66 | ; route Method.get [%r "/home/:float/"] home_float_page 67 | ; route Method.get [%r "/contact/*/:int"] contact_page 68 | ; route Method.post [%r "/home/products/**"] full_rest_page 69 | ; route Method.get [%r "/home/*/**"] wildcard_page 70 | ; route Method.get [%r "/contact/:string/:bool"] contact_page2 71 | ; route Method.post [%r "/product/:string?section=:int&q=:bool"] 72 | product_page 73 | ; route Method.get [%r "/product/:string?section=:int&q1=yes"] 74 | product_page2 75 | ; route Method.get [%r "/product/:string?section=:string&q1=yes"] 76 | product_page3 77 | ; route Method.get [%r "/fruit/:Fruit"] fruit_page 78 | ; route Method.get [%r "/"] root_page 79 | ; route Method.get [%r "/public/**"] public 80 | ; route Method.head [%r "/numbers/:int32/code/:int64/"] numbers_page 81 | ]) 82 | 83 | let pp_route r = List.hd r |> Router.pp_route Format.std_formatter 84 | 85 | let pp_match req = Router.match' req router 86 | 87 | let route1 = 88 | Router.route Method.get {%r|/home/about/:bool?h=:int&b=:bool&e=hello|} 89 | (fun _ _ _ _ -> ()) 90 | 91 | let route2 = 92 | Router.route Method.post {%r|/home/about/:int/:string/:Fruit|} (fun _ _ _ _ -> 93 | ()) 94 | 95 | let route3 = 96 | Router.route Method.head 97 | {%r|/home/:int/:int32/:int64/:Fruit?q1=hello&f=:Fruit&b=:bool&f=:float |} 98 | (fun _ _ _ _ _ _ _ _ -> ()) 99 | 100 | let make_request meth resource : request = 101 | let client_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, 8080) in 102 | Request.make_server_request ~resource meth client_addr 103 | (Eio.Buf_read.of_string "") 104 | 105 | let get = Method.get 106 | 107 | let top_1_first () = 108 | Router.add get [%r "/home/:float"] 109 | (fun f _req -> Format.sprintf "Float: %f" f) 110 | Router.empty 111 | |> Router.add get [%r "/home/:int"] (fun i _req -> 112 | Format.sprintf "Int : %d" i) 113 | |> Router.match' @@ make_request Method.get "/home/12" 114 | 115 | let top_1_first_2 () = 116 | Router.add get [%r "/home/:int"] 117 | (fun i _req -> Format.sprintf "Int : %d" i) 118 | Router.empty 119 | |> Router.add get [%r "/home/:float"] (fun f _req -> 120 | Format.sprintf "Float: %f" f) 121 | |> Router.match' @@ make_request Method.get "/home/12" 122 | 123 | let longest_match () = 124 | Router.add get [%r "/home/:int"] 125 | (fun i _req -> Format.sprintf "Int : %d" i) 126 | Router.empty 127 | |> Router.add get [%r "/home/:int/:string"] (fun i _ _req -> 128 | Format.sprintf "longest: %i" i) 129 | |> Router.match' @@ make_request Method.get "/home/12/hello" 130 | -------------------------------------------------------------------------------- /test/server.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bikallem/spring/6ca384b78b4f647c5cb5caa064cb27d2c997f04b/test/server.md -------------------------------------------------------------------------------- /test/session.md: -------------------------------------------------------------------------------- 1 | # Session unit tests 2 | 3 | ```ocaml 4 | open Spring 5 | 6 | let key = Base64.(decode_exn ~pad:false "knFR+ybPVw/DJoOn+e6vpNNU2Ip2Z3fj1sXMgEyWYhA") 7 | let nonce = Cstruct.of_string "aaaaaaaaaaaa" 8 | ``` 9 | 10 | ## Session.cookie_codec/encode/decode 11 | 12 | ```ocaml 13 | # let t = Session.cookie_codec key ;; 14 | val t : Session.codec = 15 | 16 | # let session_data = 17 | Session.Data.( 18 | add "a" "a_val" empty 19 | |> add "b" "b_val");; 20 | val session_data : string Session.Data.t = 21 | 22 | # Session.Data.find_opt "a" session_data;; 23 | - : string option = Some "a_val" 24 | 25 | # Session.Data.find_opt "b" session_data;; 26 | - : string option = Some "b_val" 27 | 28 | # let data = Session.encode ~nonce session_data t;; 29 | val data : string = 30 | "YWFhYWFhYWFhYWFhYHOdvSHL4fyIGWh0ayUSVBXbIUXq5NdJtENq4iTIX1doh_MkW46wor8-" 31 | 32 | # let t1 = Session.decode data t;; 33 | val t1 : Session.session_data = 34 | 35 | # Session.Data.find_opt "a" t1;; 36 | - : string option = Some "a_val" 37 | 38 | # Session.Data.find_opt "b" t1;; 39 | - : string option = Some "b_val" 40 | ``` 41 | -------------------------------------------------------------------------------- /test/status.md: -------------------------------------------------------------------------------- 1 | ## Status tests 2 | 3 | ```ocaml 4 | # open Spring 5 | ``` 6 | 7 | ### Status.make 8 | 9 | ```ocaml 10 | # let s = Status.make (-1) "asdf";; 11 | Exception: Failure "code: -1 is negative". 12 | 13 | # let s = Status.make 0 "asdasdf";; 14 | Exception: Failure "code: 0 is not a three-digit number". 15 | 16 | # let s = Status.make 1000 "dddd";; 17 | Exception: Failure "code: 1000 is not a three-digit number". 18 | 19 | # let s = Status.make 499 "Client Closed Request";; 20 | val s : Status.t = (499, "Client Closed Request") 21 | ``` 22 | 23 | ### Status.equal 24 | 25 | ```ocaml 26 | # Status.(equal ok ok);; 27 | - : bool = true 28 | 29 | # Status.(equal ok created) ;; 30 | - : bool = false 31 | ``` 32 | 33 | ### Status.pp 34 | 35 | ```ocaml 36 | # Status.(to_string ok);; 37 | - : string = "200 OK" 38 | ``` 39 | -------------------------------------------------------------------------------- /test/te.md: -------------------------------------------------------------------------------- 1 | ## Te tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ### Te.decode, equal 8 | 9 | ```ocaml 10 | # let t = Te.decode "trailers, deflate;q=0.5, gzip";; 11 | val t : Te.t = 12 | 13 | # Te.(exists t trailers);; 14 | - : bool = true 15 | 16 | # Te.(exists t deflate);; 17 | - : bool = true 18 | 19 | # Te.(exists t gzip);; 20 | - : bool = true 21 | 22 | # Te.(get_q t gzip);; 23 | - : string option = None 24 | 25 | # Te.(get_q t deflate);; 26 | - : string option = Some "0.5" 27 | ``` 28 | 29 | ### Te.encode 30 | 31 | ```ocaml 32 | # Te.encode t;; 33 | - : string = "trailers, deflate;q=0.5, gzip" 34 | ``` 35 | 36 | ### Te.remove 37 | 38 | ```ocaml 39 | # let t = Te.(remove t gzip);; 40 | val t : Te.t = 41 | 42 | # Te.encode t;; 43 | - : string = "trailers, deflate;q=0.5" 44 | ``` 45 | 46 | ### Te.singleton 47 | 48 | ```ocaml 49 | # let t = Te.(singleton trailers);; 50 | val t : Te.t = 51 | 52 | # Te.(exists t trailers);; 53 | - : bool = true 54 | ``` 55 | -------------------------------------------------------------------------------- /test/transfer_encoding.md: -------------------------------------------------------------------------------- 1 | # Transfer_encoding tests 2 | 3 | ```ocaml 4 | open Spring 5 | ``` 6 | 7 | ## Transfer_encoding.decode 8 | 9 | ```ocaml 10 | # let t = Transfer_encoding.decode "gzip, chunked";; 11 | val t : Transfer_encoding.t = 12 | 13 | # Transfer_encoding.(exists t chunked);; 14 | - : bool = true 15 | 16 | # Transfer_encoding.(exists t gzip);; 17 | - : bool = true 18 | 19 | # let t1 = Transfer_encoding.decode "chunked";; 20 | val t1 : Transfer_encoding.t = 21 | 22 | # Transfer_encoding.(exists t1 chunked);; 23 | - : bool = true 24 | 25 | # Transfer_encoding.(exists t1 gzip);; 26 | - : bool = false 27 | ``` 28 | 29 | ## Transfer_encoding.remove 30 | 31 | ```ocaml 32 | # let t2 = Transfer_encoding.(remove t gzip) ;; 33 | val t2 : Transfer_encoding.t = 34 | 35 | # Transfer_encoding.(exists t2 gzip) 36 | - : bool = false 37 | 38 | # Transfer_encoding.(exists t2 chunked);; 39 | - : bool = true 40 | ``` 41 | 42 | ## Transfer_encoding.encode 43 | 44 | ```ocaml 45 | # Transfer_encoding.encode t;; 46 | - : string = "gzip, chunked" 47 | 48 | # Transfer_encoding.encode t1;; 49 | - : string = "chunked" 50 | 51 | # Transfer_encoding.encode t2;; 52 | - : string = "chunked" 53 | ``` 54 | 55 | ## Transfer_encoding.singleon 56 | 57 | ```ocaml 58 | # let t = Transfer_encoding.(singleton chunked);; 59 | val t : Transfer_encoding.t = 60 | 61 | # Transfer_encoding.(exists t chunked) ;; 62 | - : bool = true 63 | ``` 64 | -------------------------------------------------------------------------------- /test/version.md: -------------------------------------------------------------------------------- 1 | # Version tests 2 | 3 | ```ocaml 4 | open Spring 5 | open Eio 6 | ``` 7 | 8 | ## Version.parser 9 | 10 | ```ocaml 11 | # let r = Buf_read.of_string "HTTP/1.1";; 12 | val r : Buf_read.t = 13 | 14 | # Version.parse r;; 15 | - : Version.t = (1, 1) 16 | 17 | # Version.parse (Buf_read.of_string "HTTP/1.0");; 18 | - : Version.t = (1, 0) 19 | ``` 20 | --------------------------------------------------------------------------------