http.ml 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. (*
  2. * _ _ ____ _
  3. * _| || |_/ ___| ___ _ __ _ __ ___ | |
  4. * |_ .. _\___ \ / _ \ '_ \| '_ \ / _ \| |
  5. * |_ _|___) | __/ |_) | |_) | (_) |_|
  6. * |_||_| |____/ \___| .__/| .__/ \___/(_)
  7. * |_| |_|
  8. *
  9. * Personal Social Web.
  10. *
  11. * Copyright (C) The #Seppo contributors. All rights reserved.
  12. *
  13. * This program is free software: you can redistribute it and/or modify
  14. * it under the terms of the GNU General Public License as published by
  15. * the Free Software Foundation, either version 3 of the License, or
  16. * (at your option) any later version.
  17. *
  18. * This program is distributed in the hope that it will be useful,
  19. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  20. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  21. * GNU General Public License for more details.
  22. *
  23. * You should have received a copy of the GNU General Public License
  24. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  25. *)
  26. let ( let* ) = Result.bind
  27. let ( let*% ) r f : ('b,'e) Lwt_result.t =
  28. (* https://discuss.ocaml.org/t/idiomatic-let-result-bind-and-lwt-bind/12554?u=mro *)
  29. match r with
  30. | Error _ as e -> Lwt.return e (* similar to Result.map_error but without unwrapping *)
  31. | Ok v -> f v
  32. let reso ~base url =
  33. Uri.resolve "https" base url
  34. let relpa base path =
  35. let rec f = function
  36. | _ :: [], p -> p
  37. | bh :: bt, ph :: pt when String.equal bh ph -> f (bt,pt)
  38. | _ -> []
  39. in
  40. let ba = base |> String.split_on_char '/'
  41. and pa = path |> String.split_on_char '/' in
  42. f (ba,pa) |> String.concat "/"
  43. let abs_to_rel ~base url =
  44. match url |> Uri.host with
  45. | None -> url
  46. | Some _ as ho ->
  47. let url = if Option.equal String.equal (Uri.host base) ho
  48. then Uri.with_host url None
  49. else url in
  50. let url = if Option.equal String.equal (Uri.scheme base) (Uri.scheme url)
  51. then Uri.with_scheme url None
  52. else url in
  53. let url = if Option.equal Int.equal (Uri.port base) (Uri.port url)
  54. then Uri.with_port url None
  55. else url in
  56. let url = Uri.with_path url (relpa (Uri.path base) (Uri.path url)) in
  57. url
  58. (* https://tools.ietf.org/html/rfc2616/#section-3.3.1
  59. https://tools.ietf.org/html/rfc1123#page-55
  60. https://tools.ietf.org/html/rfc822#section-5.1
  61. *)
  62. let to_rfc1123 (time : Ptime.t) =
  63. (* MIT License, Copyright 2021 Anton Bachin
  64. https://github.com/aantron/dream/blob/master/src/pure/formats.ml#L51 *)
  65. let weekday =
  66. match Ptime.weekday time with
  67. | `Sun -> "Sun"
  68. | `Mon -> "Mon"
  69. | `Tue -> "Tue"
  70. | `Wed -> "Wed"
  71. | `Thu -> "Thu"
  72. | `Fri -> "Fri"
  73. | `Sat -> "Sat"
  74. in
  75. let (y, m, d), ((hh, mm, ss), _tz_offset_s) = Ptime.to_date_time time in
  76. let month =
  77. match m with
  78. | 1 -> "Jan"
  79. | 2 -> "Feb"
  80. | 3 -> "Mar"
  81. | 4 -> "Apr"
  82. | 5 -> "May"
  83. | 6 -> "Jun"
  84. | 7 -> "Jul"
  85. | 8 -> "Aug"
  86. | 9 -> "Sep"
  87. | 10 -> "Oct"
  88. | 11 -> "Nov"
  89. | 12 -> "Dec"
  90. | _ -> failwith "Month < 1 or > 12 not allowed"
  91. in
  92. (* [Ptime.to_date_time] docs give range 0..60 for [ss], accounting for
  93. leap seconds. However, RFC 6265 §5.1.1 states:
  94. 5. Abort these steps and fail to parse the cookie-date if:
  95. * the second-value is greater than 59.
  96. (Note that leap seconds cannot be represented in this syntax.)
  97. See https://tools.ietf.org/html/rfc6265#section-5.1.1.
  98. Even though [Ptime.to_date_time] time does not return leap seconds, in
  99. case I misunderstood the gmtime API, of system differences, or future
  100. refactoring, make sure no leap seconds creep into the output. *)
  101. Printf.sprintf "%s, %02i %s %04i %02i:%02i:%02i GMT" weekday d month y hh mm
  102. (min 59 ss)
  103. module Mime = struct
  104. module C = As2_vocab.Constants.ContentType
  105. let _app_act_json= C._app_act_json
  106. let app_jlda = C.app_jlda
  107. let app_jrd = C.app_jrd
  108. let app_atom_xml = C.app_atom_xml
  109. let app_form_url = "application/x-www-form-urlencoded"
  110. let app_json = C.app_json
  111. let img_jpeg = "image/jpeg"
  112. let text_css = "text/css; charset=utf8"
  113. let text_html = "text/html; charset=utf8"
  114. let text_plain = "text/plain; charset=utf8"
  115. let text_xml = "text/xml"
  116. let text_xsl = "text/xsl"
  117. let is_app_json m =
  118. _app_act_json |> String.equal m
  119. || app_json |> String.equal m
  120. end
  121. module H = struct
  122. type t = string * string
  123. let add' h (n, v) = Cohttp.Header.add h n v
  124. let acc_app_json = ("Accept", Mime.app_json)
  125. let acc_app_jrd = ("Accept", Mime.app_jrd)
  126. let acc_app_jlda = ("Accept", Mime.app_jlda)
  127. let agent = ("User-Agent", St.seppo_s)
  128. let content_type ct : t = ("Content-Type", ct)
  129. let ct_jlda = content_type Mime.app_jlda
  130. let ct_html = content_type Mime.text_html
  131. let ct_json = content_type Mime.app_json
  132. let ct_plain = content_type Mime.text_plain
  133. let ct_xml = content_type Mime.text_xml
  134. let content_length cl:t = ("Content-Length", cl |> string_of_int)
  135. let location url : t = ("Location", url)
  136. let retry_after t : t = ("Retry-After", t |> to_rfc1123)
  137. let set_cookie v : t = ("Set-Cookie", v)
  138. let max_age _ : t = assert false (* set via webserver config *)
  139. let x_request_id u : t = ("X-Request-Id", Uuidm.to_string u)
  140. end
  141. module R = Cgi.Response
  142. (* See also https://github.com/aantron/dream/blob/master/src/pure/status.ml *)
  143. (* RFC1945 demands absolute uris https://www.rfc-editor.org/rfc/rfc1945#section-10.11 *)
  144. let s302 ?(header = []) url = Error (`Found, [ H.ct_plain; H.location url ] @ header, R.nobody)
  145. let s400' = (`Bad_request, [ H.ct_plain ], R.nobody)
  146. let s400 = Error s400'
  147. let s400x = Error (`Bad_request, [ H.ct_xml ], R.nobody)
  148. let s401 = Error (`Unauthorized, [ H.ct_plain ], R.nobody)
  149. let s403' = (`Forbidden, [ H.ct_plain ], R.nobody)
  150. let s403 = Error s403'
  151. let s404 = Error (`Not_found, [ H.ct_plain ], R.nobody)
  152. let s405 = Error (`Method_not_allowed, [ H.ct_plain ], R.nobody)
  153. let s413 = Error (`Code 413, [ H.ct_plain ], R.nobody) (* Payload too large *)
  154. (* https://stackoverflow.com/a/42171674/349514 *)
  155. let s422' = (`Unprocessable_entity, [ H.ct_plain ], R.nobody)
  156. let s422 = Error s422'
  157. let s422x = Error (`Unprocessable_entity, [ H.ct_xml ], R.nobody)
  158. (* https://tools.ietf.org/html/rfc6585#section-4
  159. Retry-After https://tools.ietf.org/html/rfc2616#section-14.37
  160. HTTP-date https://tools.ietf.org/html/rfc1123
  161. https://github.com/inhabitedtype/ocaml-webmachine/blob/master/lib/rfc1123.ml
  162. *)
  163. let s429_t t = Error (`Too_many_requests, [ H.ct_plain; H.retry_after t ], R.nobody )
  164. let s500' = (`Internal_server_error, [ H.ct_plain ], R.nobody)
  165. let s500 = Error s500'
  166. let s501 = Error (`Not_implemented, [ H.ct_plain ], R.nobody)
  167. let s502' = (`Bad_gateway, [ H.ct_plain ], R.nobody)
  168. let s502 = Error s502'
  169. let err500 ?(error = s500') ?(level = Logs.Error) msg e =
  170. Logr.msg level (fun m -> m "%s: %s" msg e);
  171. error
  172. let clob_send _ mime clob =
  173. Ok (`OK, [H.content_type mime], fun oc -> output_string oc clob)
  174. (*
  175. * https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
  176. * see also https://github.com/Gopiandcode/http_sig_ocaml/blob/254d464c16025e189ceb20190710fe50e9bd8d2b/http_sig.ml#L50
  177. *
  178. * Another list of k-v-pairs but in diiosyncratic encoding. Different from Cookie.
  179. *)
  180. module Signature = struct
  181. (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
  182. module P = struct
  183. open Tyre
  184. (*
  185. let _htab = char '\t'
  186. (* https://stackoverflow.com/a/52336696/349514 *)
  187. let _vchar = pcre {|[!-~]|}
  188. let _sp = char ' '
  189. (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
  190. let _tchar = pcre {|[!#$%&'*+-.^_`|~0-9a-zA-Z]|}
  191. let _obs_text = pcre {|€-ÿ|} (* %x80-FF *)
  192. *)
  193. (* https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 *)
  194. let token = pcre {|[!#$%&'*+-.^_`|~0-9a-zA-Z]+|} (* rep1 tchar *)
  195. let qdtext = pcre {|[\t !#-\[\]-~€-ÿ]|}
  196. (* htab (* HTAB *)
  197. <|> sp (* SP *)
  198. <|> char '!' (* %x21 *)
  199. <|> pcre {|[#-\[]|} (* %x23-5B *)
  200. <|> pcre {|[\]-~]|} (* %x5D-7E *)
  201. <|> obs_text
  202. *)
  203. let dquote = char '"'
  204. let quoted_pair = char '\\' *> pcre {|[\t !-~€-ÿ]|} (* (htab <|> sp <|> vchar <|> obs_text) *)
  205. let quoted_string =
  206. conv
  207. (fun x ->
  208. let buf = Buffer.create 100 in
  209. x
  210. |> Seq.fold_left (fun bu u ->
  211. (match u with
  212. | `Left ch
  213. | `Right ch -> ch)
  214. |> Buffer.add_string bu; bu) buf
  215. |> Buffer.contents)
  216. (fun x ->
  217. x
  218. |> String.to_seq
  219. |> Seq.map (fun c ->
  220. if c == '"' (* quote more? *)
  221. then `Right (String.init 1 (fun _ -> c))
  222. else `Left (String.init 1 (fun _ -> c))))
  223. (dquote *> (rep (qdtext <|> quoted_pair)) <* dquote)
  224. let ows = pcre {|[ \t]*|}
  225. let bws = ows
  226. (* https://datatracker.ietf.org/doc/html/rfc7235#section-2.1 *)
  227. let auth_param =
  228. conv
  229. (function
  230. | (t,`Left x)
  231. | (t,`Right x) -> t,x)
  232. (fun (t,s) ->
  233. (* TODO make s a token (`Left) if possible *)
  234. (t,`Right s))
  235. (token <* bws <* char '=' <* bws <&> (token <|> quoted_string))
  236. let list_auth_param =
  237. (* implement production 'credentials' at https://datatracker.ietf.org/doc/html/rfc7235#appendix-C *)
  238. let sep = bws *> char ',' <* bws in
  239. start *> separated_list ~sep auth_param <* stop
  240. (* https://gabriel.radanne.net/papers/tyre/tyre_paper.pdf#page=9 *)
  241. let list_auth_param' = compile list_auth_param
  242. end
  243. (**) (* https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-4.1 *)
  244. let decode s =
  245. (* Logr.debug (fun m -> m "%s.%s %s" "Http.Signature" "parse" s); *)
  246. Tyre.exec P.list_auth_param' s
  247. let to_sign_string ~request h =
  248. let h = h |> Cohttp.Header.to_frames in
  249. (match request with
  250. | Some (meth,uri) ->
  251. let s = Printf.sprintf "(request-target): %s %s"
  252. (meth |> String.lowercase_ascii)
  253. (uri |> Uri.path_and_query) in
  254. h |> List.cons s
  255. | _ -> h)
  256. |> String.concat "\n"
  257. end
  258. type t_sign_k = Uri.t * (Cstruct.t-> string * Cstruct.t) * Ptime.t
  259. (** Create headers including a signature for a POST request.
  260. *
  261. * https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/#http-signatures
  262. * https://socialhub.activitypub.rocks/t/help-needed-http-signatures/2458
  263. * https://tools.ietf.org/id/draft-cavage-http-signatures-12.html
  264. *
  265. * HTTP signature according https://tools.ietf.org/id/draft-cavage-http-signatures-12.html#rfc.appendix.C
  266. * https://www.ietf.org/archive/id/draft-ietf-httpbis-message-signatures-10.html#name-creating-a-signature
  267. * Digest http://tools.ietf.org/html/rfc3230#section-4.3.2
  268. *
  269. * https://docs.joinmastodon.org/spec/security/#http
  270. * https://w3id.org/security#publicKey
  271. * https://w3id.org/security/v1
  272. *
  273. * NOT: https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/
  274. *)
  275. let signed_headers (key_id,(fkt_sign : Cstruct.t -> string * Cstruct.t),date : t_sign_k) dige uri =
  276. let open Cohttp in
  277. let hdr = (
  278. ("host", uri |> Uri.host |> Option.value ~default:"-") ::
  279. ("date", date |> to_rfc1123) ::
  280. match dige with
  281. | None -> []
  282. | Some dige -> ("digest", dige) :: []
  283. ) |> Header.of_list in
  284. let meth,dige = match dige with
  285. | None -> "get", ""
  286. | Some _ -> "post"," digest"
  287. in
  288. let request = Some (meth,uri) in
  289. let algo,(sgna : Cstruct.t) = hdr
  290. |> Signature.to_sign_string ~request
  291. |> Cstruct.of_string
  292. |> fkt_sign in
  293. Printf.sprintf (* must be symmetric to Signature.decode *)
  294. "keyId=\"%s\",\
  295. algorithm=\"%s\",\
  296. headers=\"(request-target) host date%s\",\
  297. signature=\"%s\""
  298. (key_id |> Uri.to_string)
  299. algo
  300. dige
  301. (sgna |> Cstruct.to_string |> Base64.encode_exn)
  302. |> Header.add hdr "signature"
  303. (* Logr.debug (fun m -> m "%s.%s\n%s" "Http" "signed_headers" (r |> Header.to_string)); *)
  304. (* https://github.com/mirage/ocaml-cohttp#dealing-with-timeouts *)
  305. let timeout ~seconds ~f =
  306. try%lwt
  307. Lwt.pick
  308. [
  309. Lwt.map Result.ok (f ()) ;
  310. Lwt.map (fun () -> Error "Timeout") (Lwt_unix.sleep seconds);
  311. ]
  312. with
  313. | Failure s -> Lwt.return (Error s)
  314. (* don't care about maximum redirects but rather enforce a timeout *)
  315. let get
  316. ?(key : t_sign_k option = None)
  317. ?(seconds = 5.0)
  318. ?(headers = Cohttp.Header.init())
  319. uri =
  320. let t0 = Sys.time () in
  321. let uuid = Uuidm.v `V4 in
  322. let headers = H.agent |> H.add' headers in
  323. let headers = uuid |> H.x_request_id |> H.add' headers in
  324. (* based on https://github.com/mirage/ocaml-cohttp#dealing-with-redirects *)
  325. let rec get_follow uri =
  326. let headers = match key with
  327. | None -> headers
  328. | Some key ->
  329. Cohttp.Header.(signed_headers key None uri |> to_list |> add_list headers) in
  330. let%lwt r = Cohttp_lwt_unix.Client.get ~headers uri in
  331. follow_redirect ~base:uri r
  332. and follow_redirect ~base (response, body) =
  333. let sta = response |> Cohttp.Response.status in
  334. Logr.debug (fun m -> m "%s.%s %a %s" "Http" "get" Uuidm.pp uuid (Cohttp.Code.string_of_status sta));
  335. match sta with
  336. | #Cohttp.Code.redirection_status as sta ->
  337. (* if response |> Cohttp.Response.status |> Cohttp.Code.code_of_status |> Cohttp.Code.is_redirection *)
  338. (* should we ignore the status and just use location if present? *)
  339. ( match "location" |> Cohttp.Header.get (Cohttp.Response.headers response) with
  340. | Some loc ->
  341. Logr.debug (fun m -> m "%s.%s HTTP %d location: %s" "Http" "get" (Cohttp.Code.code_of_status sta) loc);
  342. let loc = loc |> Uri.of_string |> reso ~base in
  343. let fol () = get_follow loc in
  344. (* The unconsumed body would leak memory *)
  345. let%lwt p = Cohttp_lwt.Body.drain_body body in
  346. fol p
  347. | None ->
  348. Logr.warn (fun m -> m "%s.%s missing location header %a" "Http" "get" Uri.pp_hum base);
  349. Lwt.return (response, body) )
  350. | _ -> Lwt.return (response, body)
  351. and f () = get_follow uri in
  352. let r = timeout ~seconds ~f in
  353. Logr.info (fun m -> m "%s.%s %a dt=%.3fs localhost -> %a" "Http" "get" Uuidm.pp uuid (Sys.time() -. t0) Uri.pp uri);
  354. r
  355. let post
  356. ?(seconds = 5.0)
  357. ~headers
  358. body
  359. uri : 'a Lwt.t =
  360. let t0 = Sys.time () in
  361. let uuid = Uuidm.v `V4 in
  362. let headers = uuid |> H.x_request_id |> H.add' headers in
  363. let headers = H.agent |> H.add' headers in
  364. let headers = body |> String.length |> H.content_length |> H.add' headers in
  365. let f () = Cohttp_lwt_unix.Client.post ~body:(`String body) ~headers uri in
  366. let r = timeout ~seconds ~f in
  367. Logr.info (fun m -> m "%s.%s %a dt=%.3fs localhost -> %a" "Http" "post" Uuidm.pp uuid (Sys.time() -. t0) Uri.pp uri);
  368. Logr.debug (fun m -> m "%s.%s\n%s%s" "Http" "post" (headers |> Cohttp.Header.to_string) body);
  369. r
  370. let get_jsonv
  371. ?(key = None)
  372. ?(seconds = 5.0)
  373. ?(headers = [ H.acc_app_jlda ] |> Cohttp.Header.of_list)
  374. fkt
  375. uri =
  376. let%lwt p = get ~key ~seconds ~headers uri in
  377. match p with
  378. | Error _ as e -> Lwt.return e
  379. | Ok (_resp, body) ->
  380. let%lwt body = body |> Cohttp_lwt.Body.to_string in
  381. (try
  382. body
  383. |> Ezjsonm.value_from_string
  384. |> fkt
  385. |> Lwt.return
  386. with
  387. | Ezjsonm.Parse_error (_,msg) ->
  388. Error ("parsing as json: '" ^ msg ^ "'")
  389. |> Lwt.return
  390. | e ->
  391. Logr.err (fun m -> m "%s %s.%s: %s" E.e1013 "Http" "get_jsonv" body);
  392. raise e)
  393. let get_jsonv'
  394. ?(key : t_sign_k option = None)
  395. ?(seconds = 5.0)
  396. ?(headers = [ H.acc_app_jlda ] |> Cohttp.Header.of_list)
  397. fkt
  398. uri =
  399. let%lwt p = get ~key ~seconds ~headers uri in
  400. match p with
  401. | Error _ as e -> Lwt.return e
  402. | Ok (resp, body) ->
  403. let%lwt body = body |> Cohttp_lwt.Body.to_string in
  404. let body = (try
  405. body
  406. |> Ezjsonm.value_from_string
  407. with _ ->
  408. Logr.err (fun m -> m "%s %s.%s parsing as json: %s" E.e1014 "Http" "get_jsonv'" body);
  409. `Null) in
  410. fkt (resp,body)
  411. |> Lwt.return
  412. let plain2html s =
  413. s
  414. |> Lexing.from_string
  415. |> Plain2html.url (Buffer.create 100)
  416. |> Buffer.contents
  417. module Form = struct
  418. type field = string * string list (* name and values *)
  419. type t = field list
  420. (* https://discuss.ocaml.org/t/decoding-x-www-form-urlencoded/4505/3?u=mro *)
  421. (* application/x-www-form-urlencoded *)
  422. let of_string s : t = Uri.query_of_encoded s
  423. let of_channel ic = ic |> input_line |> of_string
  424. (*
  425. let sort (l : t) : t =
  426. l |> List.sort (fun (a, _) (b, _) -> String.compare a b)
  427. let filter_sort f l = l |> List.filter f |> sort
  428. let filter_sort_keys (ks : string list) l =
  429. l |> filter_sort (fun (k, _) -> List.exists (String.equal k) ks)
  430. *)
  431. (* define a form with fields & constraints (name,type, [(att,val)]) *)
  432. type input = string * string * (string * string) list
  433. let validate name ty v attr =
  434. let vali ty (an,av) v =
  435. match ty,an with
  436. | _,"maxlength" ->
  437. Logr.debug (fun m -> m " validate %s='%s'" an av);
  438. (* http://www.w3.org/TR/html5/forms.html#the-maxlength-and-minlength-attributes
  439. https://wiki.selfhtml.org/wiki/HTML/Elemente/input *)
  440. (match av |> int_of_string_opt with
  441. | None -> Error (name,"invalid maxlength")
  442. | Some max -> if String.length v <= max
  443. then Ok v
  444. else Error (name,"longer than maxlength"))
  445. | _,"minlength" ->
  446. Logr.debug (fun m -> m " validate %s='%s'" an av);
  447. (* http://www.w3.org/TR/html5/forms.html#the-maxlength-and-minlength-attributes
  448. https://wiki.selfhtml.org/wiki/HTML/Elemente/input *)
  449. (match av |> int_of_string_opt with
  450. | None -> Error (name,"invalid minlength")
  451. | Some min -> if String.length v >= min
  452. then Ok v
  453. else Error (name,"shorter than minlength"))
  454. | _,"pattern" ->
  455. Logr.debug (fun m -> m " '%s' ~ /%s/" v av);
  456. (try
  457. let rx = Re.Pcre.regexp av in
  458. if Re.execp rx v
  459. then Ok v
  460. else Error (name,"pattern mismatch")
  461. with | _ -> Error (name,"invalid pattern"))
  462. | _ -> Ok v
  463. in
  464. Result.bind v (vali ty attr)
  465. let string_opt ((name,ty,constraints) : input) (vals : t) : (string option, string * string) result =
  466. Logr.debug (fun m -> m " <input name='%s' ..." name);
  467. match List.assoc_opt name vals with
  468. | None ->
  469. (match List.assoc_opt "required" constraints with
  470. | None -> Ok None
  471. | Some _ -> Error (name, "required but missing"))
  472. | Some v ->
  473. let* s = List.fold_left
  474. (validate name ty)
  475. (v |> String.concat "" |> Result.ok)
  476. constraints in
  477. Ok (Some s)
  478. let string (name,ty,contraints) va : (string, string * string) result =
  479. match string_opt (name,ty,contraints) va with
  480. | Error _ as e -> e
  481. | Ok None -> Logr.err (fun m -> m "%s Field '%s' must be 'required' to use 'string'" E.e1012 name);
  482. Error (name, "implicitly required but missing")
  483. | Ok (Some v) -> Ok v
  484. end