this repo has no description
1(** 2 * JMAP protocol implementation based on RFC8620 3 * https://datatracker.ietf.org/doc/html/rfc8620 4 *) 5 6(** Whether to redact sensitive information *) 7let should_redact_sensitive = ref true 8 9(** Initialize and configure logging for JMAP *) 10let init_logging ?(level=2) ?(enable_logs=true) ?(redact_sensitive=true) () = 11 if enable_logs then begin 12 Logs.set_reporter (Logs.format_reporter ()); 13 match level with 14 | 0 -> Logs.set_level None 15 | 1 -> Logs.set_level (Some Logs.Error) 16 | 2 -> Logs.set_level (Some Logs.Info) 17 | 3 -> Logs.set_level (Some Logs.Debug) 18 | _ -> Logs.set_level (Some Logs.Debug) 19 end else 20 Logs.set_level None; 21 should_redact_sensitive := redact_sensitive 22 23(** Redact sensitive data like tokens *) 24let redact_token ?(redact=true) token = 25 if redact && !should_redact_sensitive && String.length token > 8 then 26 let prefix = String.sub token 0 4 in 27 let suffix = String.sub token (String.length token - 4) 4 in 28 prefix ^ "..." ^ suffix 29 else 30 token 31 32(** Redact sensitive headers like Authorization *) 33let redact_headers headers = 34 List.map (fun (k, v) -> 35 if String.lowercase_ascii k = "authorization" then 36 if !should_redact_sensitive then 37 let parts = String.split_on_char ' ' v in 38 match parts with 39 | scheme :: token :: _ -> (k, scheme ^ " " ^ redact_token token) 40 | _ -> (k, v) 41 else (k, v) 42 else (k, v) 43 ) headers 44 45(* Initialize logging with defaults *) 46let () = init_logging () 47 48(** Module for managing JMAP capability URIs and other constants *) 49module Capability = struct 50 (** JMAP capability URI as specified in RFC8620 *) 51 let core_uri = "urn:ietf:params:jmap:core" 52 53 (** All JMAP capability types *) 54 type t = 55 | Core (** Core JMAP capability *) 56 | Extension of string (** Extension capabilities *) 57 58 (** Convert capability to URI string *) 59 let to_string = function 60 | Core -> core_uri 61 | Extension s -> s 62 63 (** Parse a string to a capability, returns Extension for non-core capabilities *) 64 let of_string s = 65 if s = core_uri then Core 66 else Extension s 67 68 (** Check if a capability matches a core capability *) 69 let is_core = function 70 | Core -> true 71 | Extension _ -> false 72 73 (** Check if a capability string is a core capability *) 74 let is_core_string s = s = core_uri 75 76 (** Create a list of capability strings *) 77 let strings_of_capabilities capabilities = 78 List.map to_string capabilities 79end 80 81module Types = struct 82 (** Id string as per Section 1.2 *) 83 type id = string 84 85 (** Int bounded within the range -2^53+1 to 2^53-1 as per Section 1.3 *) 86 type int_t = int 87 88 (** UnsignedInt bounded within the range 0 to 2^53-1 as per Section 1.3 *) 89 type unsigned_int = int 90 91 (** Date string in RFC3339 format as per Section 1.4 *) 92 type date = string 93 94 (** UTCDate is a Date with 'Z' time zone as per Section 1.4 *) 95 type utc_date = string 96 97 (** Error object as per Section 3.6.2 *) 98 type error = { 99 type_: string; 100 description: string option; 101 } 102 103 (** Set error object as per Section 5.3 *) 104 type set_error = { 105 type_: string; 106 description: string option; 107 properties: string list option; 108 (* Additional properties for specific error types *) 109 existing_id: id option; (* For alreadyExists error *) 110 } 111 112 (** Invocation object as per Section 3.2 *) 113 type 'a invocation = { 114 name: string; 115 arguments: 'a; 116 method_call_id: string; 117 } 118 119 (** ResultReference object as per Section 3.7 *) 120 type result_reference = { 121 result_of: string; 122 name: string; 123 path: string; 124 } 125 126 (** FilterOperator, FilterCondition and Filter as per Section 5.5 *) 127 type filter_operator = { 128 operator: string; (* "AND", "OR", "NOT" *) 129 conditions: filter list; 130 } 131 and filter_condition = (string * Ezjsonm.value) list 132 and filter = 133 | Operator of filter_operator 134 | Condition of filter_condition 135 136 (** Comparator object for sorting as per Section 5.5 *) 137 type comparator = { 138 property: string; 139 is_ascending: bool option; (* Optional, defaults to true *) 140 collation: string option; (* Optional, server-dependent default *) 141 } 142 143 (** PatchObject as per Section 5.3 *) 144 type patch_object = (string * Ezjsonm.value) list 145 146 (** AddedItem structure as per Section 5.6 *) 147 type added_item = { 148 id: id; 149 index: unsigned_int; 150 } 151 152 (** Account object as per Section 1.6.2 *) 153 type account = { 154 name: string; 155 is_personal: bool; 156 is_read_only: bool; 157 account_capabilities: (string * Ezjsonm.value) list; 158 } 159 160 (** Core capability object as per Section 2 *) 161 type core_capability = { 162 max_size_upload: unsigned_int; 163 max_concurrent_upload: unsigned_int; 164 max_size_request: unsigned_int; 165 max_concurrent_requests: unsigned_int; 166 max_calls_in_request: unsigned_int; 167 max_objects_in_get: unsigned_int; 168 max_objects_in_set: unsigned_int; 169 collation_algorithms: string list; 170 } 171 172 (** PushSubscription keys object as per Section 7.2 *) 173 type push_keys = { 174 p256dh: string; 175 auth: string; 176 } 177 178 (** Session object as per Section 2 *) 179 type session = { 180 capabilities: (string * Ezjsonm.value) list; 181 accounts: (id * account) list; 182 primary_accounts: (string * id) list; 183 username: string; 184 api_url: string; 185 download_url: string; 186 upload_url: string; 187 event_source_url: string option; 188 state: string; 189 } 190 191 (** TypeState for state changes as per Section 7.1 *) 192 type type_state = (string * string) list 193 194 (** StateChange object as per Section 7.1 *) 195 type state_change = { 196 changed: (id * type_state) list; 197 } 198 199 (** PushVerification object as per Section 7.2.2 *) 200 type push_verification = { 201 push_subscription_id: id; 202 verification_code: string; 203 } 204 205 (** PushSubscription object as per Section 7.2 *) 206 type push_subscription = { 207 id: id; 208 device_client_id: string; 209 url: string; 210 keys: push_keys option; 211 verification_code: string option; 212 expires: utc_date option; 213 types: string list option; 214 } 215 216 (** Request object as per Section 3.3 *) 217 type request = { 218 using: string list; 219 method_calls: Ezjsonm.value invocation list; 220 created_ids: (id * id) list option; 221 } 222 223 (** Response object as per Section 3.4 *) 224 type response = { 225 method_responses: Ezjsonm.value invocation list; 226 created_ids: (id * id) list option; 227 session_state: string; 228 } 229 230 (** Standard method arguments and responses *) 231 232 (** Arguments for Foo/get method as per Section 5.1 *) 233 type 'a get_arguments = { 234 account_id: id; 235 ids: id list option; 236 properties: string list option; 237 } 238 239 (** Response for Foo/get method as per Section 5.1 *) 240 type 'a get_response = { 241 account_id: id; 242 state: string; 243 list: 'a list; 244 not_found: id list; 245 } 246 247 (** Arguments for Foo/changes method as per Section 5.2 *) 248 type changes_arguments = { 249 account_id: id; 250 since_state: string; 251 max_changes: unsigned_int option; 252 } 253 254 (** Response for Foo/changes method as per Section 5.2 *) 255 type changes_response = { 256 account_id: id; 257 old_state: string; 258 new_state: string; 259 has_more_changes: bool; 260 created: id list; 261 updated: id list; 262 destroyed: id list; 263 } 264 265 (** Arguments for Foo/set method as per Section 5.3 *) 266 type 'a set_arguments = { 267 account_id: id; 268 if_in_state: string option; 269 create: (id * 'a) list option; 270 update: (id * patch_object) list option; 271 destroy: id list option; 272 } 273 274 (** Response for Foo/set method as per Section 5.3 *) 275 type 'a set_response = { 276 account_id: id; 277 old_state: string option; 278 new_state: string; 279 created: (id * 'a) list option; 280 updated: (id * 'a option) list option; 281 destroyed: id list option; 282 not_created: (id * set_error) list option; 283 not_updated: (id * set_error) list option; 284 not_destroyed: (id * set_error) list option; 285 } 286 287 (** Arguments for Foo/copy method as per Section 5.4 *) 288 type 'a copy_arguments = { 289 from_account_id: id; 290 if_from_in_state: string option; 291 account_id: id; 292 if_in_state: string option; 293 create: (id * 'a) list; 294 on_success_destroy_original: bool option; 295 destroy_from_if_in_state: string option; 296 } 297 298 (** Response for Foo/copy method as per Section 5.4 *) 299 type 'a copy_response = { 300 from_account_id: id; 301 account_id: id; 302 old_state: string option; 303 new_state: string; 304 created: (id * 'a) list option; 305 not_created: (id * set_error) list option; 306 } 307 308 (** Arguments for Foo/query method as per Section 5.5 *) 309 type query_arguments = { 310 account_id: id; 311 filter: filter option; 312 sort: comparator list option; 313 position: int_t option; 314 anchor: id option; 315 anchor_offset: int_t option; 316 limit: unsigned_int option; 317 calculate_total: bool option; 318 } 319 320 (** Response for Foo/query method as per Section 5.5 *) 321 type query_response = { 322 account_id: id; 323 query_state: string; 324 can_calculate_changes: bool; 325 position: unsigned_int; 326 ids: id list; 327 total: unsigned_int option; 328 limit: unsigned_int option; 329 } 330 331 (** Arguments for Foo/queryChanges method as per Section 5.6 *) 332 type query_changes_arguments = { 333 account_id: id; 334 filter: filter option; 335 sort: comparator list option; 336 since_query_state: string; 337 max_changes: unsigned_int option; 338 up_to_id: id option; 339 calculate_total: bool option; 340 } 341 342 (** Response for Foo/queryChanges method as per Section 5.6 *) 343 type query_changes_response = { 344 account_id: id; 345 old_query_state: string; 346 new_query_state: string; 347 total: unsigned_int option; 348 removed: id list; 349 added: added_item list option; 350 } 351 352 (** Arguments for Blob/copy method as per Section 6.3 *) 353 type blob_copy_arguments = { 354 from_account_id: id; 355 account_id: id; 356 blob_ids: id list; 357 } 358 359 (** Response for Blob/copy method as per Section 6.3 *) 360 type blob_copy_response = { 361 from_account_id: id; 362 account_id: id; 363 copied: (id * id) list option; 364 not_copied: (id * set_error) list option; 365 } 366 367 (** Upload response as per Section 6.1 *) 368 type upload_response = { 369 account_id: id; 370 blob_id: id; 371 type_: string; 372 size: unsigned_int; 373 } 374 375 (** Problem details object as per RFC7807 and Section 3.6.1 *) 376 type problem_details = { 377 type_: string; 378 status: int option; 379 detail: string option; 380 limit: string option; (* For "limit" error *) 381 } 382end 383 384(** Module for working with ResultReferences as described in Section 3.7 of RFC8620 *) 385module ResultReference = struct 386 open Types 387 388 (** Create a reference to a previous method result *) 389 let create ~result_of ~name ~path = 390 { result_of; name; path } 391 392 (** Create a JSON pointer path to access a specific property *) 393 let property_path property = 394 "/" ^ property 395 396 (** Create a JSON pointer path to access all items in an array with a specific property *) 397 let array_items_path ?(property="") array_property = 398 let base = "/" ^ array_property ^ "/*" in 399 if property = "" then base 400 else base ^ "/" ^ property 401 402 (** Create argument with result reference. 403 Returns string key prefixed with # and ResultReference value. *) 404 let reference_arg arg_name ref_obj = 405 (* Prefix argument name with # *) 406 let prefixed_name = "#" ^ arg_name in 407 408 (* Convert reference object to JSON *) 409 let json_value = `O [ 410 ("resultOf", `String ref_obj.result_of); 411 ("name", `String ref_obj.name); 412 ("path", `String ref_obj.path) 413 ] in 414 415 (prefixed_name, json_value) 416 417 (** Create a reference to all IDs returned by a query method *) 418 let query_ids ~result_of = 419 create 420 ~result_of 421 ~name:"Foo/query" 422 ~path:"/ids" 423 424 (** Create a reference to properties of objects returned by a get method *) 425 let get_property ~result_of ~property = 426 create 427 ~result_of 428 ~name:"Foo/get" 429 ~path:("/list/*/" ^ property) 430end 431 432module Api = struct 433 open Lwt.Syntax 434 open Types 435 436 (** Error that may occur during API requests *) 437 type error = 438 | Connection_error of string 439 | HTTP_error of int * string 440 | Parse_error of string 441 | Authentication_error 442 443 (** Result type for API operations *) 444 type 'a result = ('a, error) Stdlib.result 445 446 (** Convert an error to a human-readable string *) 447 let string_of_error = function 448 | Connection_error msg -> "Connection error: " ^ msg 449 | HTTP_error (code, body) -> Printf.sprintf "HTTP error %d: %s" code body 450 | Parse_error msg -> "Parse error: " ^ msg 451 | Authentication_error -> "Authentication error" 452 453 (** Pretty-print an error to a formatter *) 454 let pp_error ppf err = 455 Format.fprintf ppf "%s" (string_of_error err) 456 457 (** Configuration for a JMAP API client *) 458 type config = { 459 api_uri: Uri.t; 460 username: string; 461 authentication_token: string; 462 } 463 464 (** Convert Ezjsonm.value to string *) 465 let json_to_string json = 466 Ezjsonm.value_to_string ~minify:false json 467 468 (** Parse response string as JSON value *) 469 let parse_json_string str = 470 try Ok (Ezjsonm.from_string str) 471 with e -> Error (Parse_error (Printexc.to_string e)) 472 473 (** Parse JSON response as a JMAP response object *) 474 let parse_response json = 475 try 476 let method_responses = 477 match Ezjsonm.find json ["methodResponses"] with 478 | `A items -> 479 List.map (fun json -> 480 match json with 481 | `A [`String name; args; `String method_call_id] -> 482 { name; arguments = args; method_call_id } 483 | _ -> raise (Invalid_argument "Invalid invocation format in response") 484 ) items 485 | _ -> raise (Invalid_argument "methodResponses is not an array") 486 in 487 let created_ids_opt = 488 try 489 let obj = Ezjsonm.find json ["createdIds"] in 490 match obj with 491 | `O items -> Some (List.map (fun (k, v) -> 492 match v with 493 | `String id -> (k, id) 494 | _ -> raise (Invalid_argument "createdIds value is not a string") 495 ) items) 496 | _ -> None 497 with Not_found -> None 498 in 499 let session_state = 500 match Ezjsonm.find json ["sessionState"] with 501 | `String s -> s 502 | _ -> raise (Invalid_argument "sessionState is not a string") 503 in 504 Ok { method_responses; created_ids = created_ids_opt; session_state } 505 with 506 | Not_found -> Error (Parse_error "Required field not found in response") 507 | Invalid_argument msg -> Error (Parse_error msg) 508 | e -> Error (Parse_error (Printexc.to_string e)) 509 510 (** Serialize a JMAP request object to JSON *) 511 let serialize_request req = 512 let method_calls_json = 513 `A (List.map (fun (inv : 'a invocation) -> 514 `A [`String inv.name; inv.arguments; `String inv.method_call_id] 515 ) req.method_calls) 516 in 517 let using_json = `A (List.map (fun s -> `String s) req.using) in 518 let json = `O [ 519 ("using", using_json); 520 ("methodCalls", method_calls_json) 521 ] in 522 let json = match req.created_ids with 523 | Some ids -> 524 let created_ids_json = `O (List.map (fun (k, v) -> (k, `String v)) ids) in 525 Ezjsonm.update json ["createdIds"] (Some created_ids_json) 526 | None -> json 527 in 528 json_to_string json 529 530 (** Make a raw HTTP request *) 531 let make_http_request ~method_ ~headers ~body uri = 532 let open Cohttp in 533 let open Cohttp_lwt_unix in 534 let headers = Header.add_list (Header.init ()) headers in 535 536 (* Print detailed request information to stderr for debugging *) 537 let header_list = Cohttp.Header.to_list headers in 538 let redacted_headers = redact_headers header_list in 539 Logs.info (fun m -> 540 m "\n===== HTTP REQUEST =====\n\ 541 URI: %s\n\ 542 METHOD: %s\n\ 543 HEADERS:\n%s\n\ 544 BODY:\n%s\n\ 545 ======================\n" 546 (Uri.to_string uri) 547 method_ 548 (String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers)) 549 body); 550 551 (* Force printing to stderr for immediate debugging *) 552 Printf.eprintf "[DEBUG-REQUEST] URI: %s\n" (Uri.to_string uri); 553 Printf.eprintf "[DEBUG-REQUEST] METHOD: %s\n" method_; 554 Printf.eprintf "[DEBUG-REQUEST] BODY: %s\n%!" body; 555 556 Lwt.catch 557 (fun () -> 558 let* resp, body = 559 match method_ with 560 | "GET" -> Client.get ~headers uri 561 | "POST" -> Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body) uri 562 | _ -> failwith (Printf.sprintf "Unsupported HTTP method: %s" method_) 563 in 564 let* body_str = Cohttp_lwt.Body.to_string body in 565 let status = Response.status resp |> Code.code_of_status in 566 567 (* Print detailed response information to stderr for debugging *) 568 let header_list = Cohttp.Header.to_list (Response.headers resp) in 569 let redacted_headers = redact_headers header_list in 570 Logs.info (fun m -> 571 m "\n===== HTTP RESPONSE =====\n\ 572 STATUS: %d\n\ 573 HEADERS:\n%s\n\ 574 BODY:\n%s\n\ 575 ======================\n" 576 status 577 (String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers)) 578 body_str); 579 580 (* Force printing to stderr for immediate debugging *) 581 Printf.eprintf "[DEBUG-RESPONSE] STATUS: %d\n" status; 582 Printf.eprintf "[DEBUG-RESPONSE] BODY: %s\n%!" body_str; 583 584 if status >= 200 && status < 300 then 585 Lwt.return (Ok body_str) 586 else 587 Lwt.return (Error (HTTP_error (status, body_str)))) 588 (fun e -> 589 let error_msg = Printexc.to_string e in 590 Printf.eprintf "[DEBUG-ERROR] %s\n%!" error_msg; 591 Logs.err (fun m -> m "%s" error_msg); 592 Lwt.return (Error (Connection_error error_msg))) 593 594 (** Make a raw JMAP API request 595 596 TODO:claude *) 597 let make_request config req = 598 let body = serialize_request req in 599 (* Choose appropriate authorization header based on whether it's a bearer token or basic auth *) 600 let auth_header = 601 if String.length config.username > 0 then 602 (* Standard username/password authentication *) 603 "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token) 604 else 605 (* API token (bearer authentication) *) 606 "Bearer " ^ config.authentication_token 607 in 608 609 (* Log auth header at debug level with redaction *) 610 let redacted_header = 611 if String.length config.username > 0 then 612 "Basic " ^ redact_token (Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) 613 else 614 "Bearer " ^ redact_token config.authentication_token 615 in 616 Logs.debug (fun m -> m "Using authorization header: %s" redacted_header); 617 618 let headers = [ 619 ("Content-Type", "application/json"); 620 ("Content-Length", string_of_int (String.length body)); 621 ("Authorization", auth_header) 622 ] in 623 let* result = make_http_request ~method_:"POST" ~headers ~body config.api_uri in 624 match result with 625 | Ok response_body -> 626 (match parse_json_string response_body with 627 | Ok json -> 628 Logs.debug (fun m -> m "Successfully parsed JSON response"); 629 Lwt.return (parse_response json) 630 | Error e -> 631 let msg = match e with Parse_error m -> m | _ -> "unknown error" in 632 Logs.err (fun m -> m "Failed to parse response: %s" msg); 633 Lwt.return (Error e)) 634 | Error e -> 635 (match e with 636 | Connection_error msg -> Logs.err (fun m -> m "Connection error: %s" msg) 637 | HTTP_error (code, _) -> Logs.err (fun m -> m "HTTP error %d" code) 638 | Parse_error msg -> Logs.err (fun m -> m "Parse error: %s" msg) 639 | Authentication_error -> Logs.err (fun m -> m "Authentication error")); 640 Lwt.return (Error e) 641 642 (** Parse a JSON object as a Session object *) 643 let parse_session_object json = 644 try 645 let capabilities = 646 match Ezjsonm.find json ["capabilities"] with 647 | `O items -> items 648 | _ -> raise (Invalid_argument "capabilities is not an object") 649 in 650 651 let accounts = 652 match Ezjsonm.find json ["accounts"] with 653 | `O items -> List.map (fun (id, json) -> 654 match json with 655 | `O _ -> 656 let name = Ezjsonm.get_string (Ezjsonm.find json ["name"]) in 657 let is_personal = Ezjsonm.get_bool (Ezjsonm.find json ["isPersonal"]) in 658 let is_read_only = Ezjsonm.get_bool (Ezjsonm.find json ["isReadOnly"]) in 659 let account_capabilities = 660 match Ezjsonm.find json ["accountCapabilities"] with 661 | `O items -> items 662 | _ -> raise (Invalid_argument "accountCapabilities is not an object") 663 in 664 (id, { name; is_personal; is_read_only; account_capabilities }) 665 | _ -> raise (Invalid_argument "account value is not an object") 666 ) items 667 | _ -> raise (Invalid_argument "accounts is not an object") 668 in 669 670 let primary_accounts = 671 match Ezjsonm.find_opt json ["primaryAccounts"] with 672 | Some (`O items) -> List.map (fun (k, v) -> 673 match v with 674 | `String id -> (k, id) 675 | _ -> raise (Invalid_argument "primaryAccounts value is not a string") 676 ) items 677 | Some _ -> raise (Invalid_argument "primaryAccounts is not an object") 678 | None -> [] 679 in 680 681 let username = Ezjsonm.get_string (Ezjsonm.find json ["username"]) in 682 let api_url = Ezjsonm.get_string (Ezjsonm.find json ["apiUrl"]) in 683 let download_url = Ezjsonm.get_string (Ezjsonm.find json ["downloadUrl"]) in 684 let upload_url = Ezjsonm.get_string (Ezjsonm.find json ["uploadUrl"]) in 685 let event_source_url = 686 try Some (Ezjsonm.get_string (Ezjsonm.find json ["eventSourceUrl"])) 687 with Not_found -> None 688 in 689 let state = Ezjsonm.get_string (Ezjsonm.find json ["state"]) in 690 691 Ok { capabilities; accounts; primary_accounts; username; 692 api_url; download_url; upload_url; event_source_url; state } 693 with 694 | Not_found -> Error (Parse_error "Required field not found in session object") 695 | Invalid_argument msg -> Error (Parse_error msg) 696 | e -> Error (Parse_error (Printexc.to_string e)) 697 698 (** Fetch a Session object from a JMAP server 699 700 TODO:claude *) 701 let get_session uri ?username ?authentication_token ?api_token () = 702 let headers = 703 match (username, authentication_token, api_token) with 704 | (Some u, Some t, _) -> 705 let auth = "Basic " ^ Base64.encode_string (u ^ ":" ^ t) in 706 let redacted_auth = "Basic " ^ redact_token (Base64.encode_string (u ^ ":" ^ t)) in 707 Logs.info (fun m -> m "Session using Basic auth: %s" redacted_auth); 708 [ 709 ("Content-Type", "application/json"); 710 ("Authorization", auth) 711 ] 712 | (_, _, Some token) -> 713 let auth = "Bearer " ^ token in 714 let redacted_token = redact_token token in 715 Logs.info (fun m -> m "Session using Bearer auth: %s" ("Bearer " ^ redacted_token)); 716 [ 717 ("Content-Type", "application/json"); 718 ("Authorization", auth) 719 ] 720 | _ -> [("Content-Type", "application/json")] 721 in 722 723 let* result = make_http_request ~method_:"GET" ~headers ~body:"" uri in 724 match result with 725 | Ok response_body -> 726 (match parse_json_string response_body with 727 | Ok json -> 728 Logs.debug (fun m -> m "Successfully parsed session response"); 729 Lwt.return (parse_session_object json) 730 | Error e -> 731 let msg = match e with Parse_error m -> m | _ -> "unknown error" in 732 Logs.err (fun m -> m "Failed to parse session response: %s" msg); 733 Lwt.return (Error e)) 734 | Error e -> 735 let err_msg = match e with 736 | Connection_error msg -> "Connection error: " ^ msg 737 | HTTP_error (code, _) -> Printf.sprintf "HTTP error %d" code 738 | Parse_error msg -> "Parse error: " ^ msg 739 | Authentication_error -> "Authentication error" 740 in 741 Logs.err (fun m -> m "Failed to get session: %s" err_msg); 742 Lwt.return (Error e) 743 744 (** Upload a binary blob to the server 745 746 TODO:claude *) 747 let upload_blob config ~account_id ~content_type data = 748 let upload_url_template = config.api_uri |> Uri.to_string in 749 (* Replace {accountId} with the actual account ID *) 750 let upload_url = Str.global_replace (Str.regexp "{accountId}") account_id upload_url_template in 751 let upload_uri = Uri.of_string upload_url in 752 753 let headers = [ 754 ("Content-Type", content_type); 755 ("Content-Length", string_of_int (String.length data)); 756 ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) 757 ] in 758 759 let* result = make_http_request ~method_:"POST" ~headers ~body:data upload_uri in 760 match result with 761 | Ok response_body -> 762 (match parse_json_string response_body with 763 | Ok json -> 764 (try 765 let account_id = Ezjsonm.get_string (Ezjsonm.find json ["accountId"]) in 766 let blob_id = Ezjsonm.get_string (Ezjsonm.find json ["blobId"]) in 767 let type_ = Ezjsonm.get_string (Ezjsonm.find json ["type"]) in 768 let size = Ezjsonm.get_int (Ezjsonm.find json ["size"]) in 769 Lwt.return (Ok { account_id; blob_id; type_; size }) 770 with 771 | Not_found -> Lwt.return (Error (Parse_error "Required field not found in upload response")) 772 | e -> Lwt.return (Error (Parse_error (Printexc.to_string e)))) 773 | Error e -> Lwt.return (Error e)) 774 | Error e -> Lwt.return (Error e) 775 776 (** Download a binary blob from the server 777 778 TODO:claude *) 779 let download_blob config ~account_id ~blob_id ?type_ ?name () = 780 let download_url_template = config.api_uri |> Uri.to_string in 781 782 (* Replace template variables with actual values *) 783 let url = Str.global_replace (Str.regexp "{accountId}") account_id download_url_template in 784 let url = Str.global_replace (Str.regexp "{blobId}") blob_id url in 785 786 let url = match type_ with 787 | Some t -> Str.global_replace (Str.regexp "{type}") (Uri.pct_encode t) url 788 | None -> Str.global_replace (Str.regexp "{type}") "" url 789 in 790 791 let url = match name with 792 | Some n -> Str.global_replace (Str.regexp "{name}") (Uri.pct_encode n) url 793 | None -> Str.global_replace (Str.regexp "{name}") "file" url 794 in 795 796 let download_uri = Uri.of_string url in 797 798 let headers = [ 799 ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) 800 ] in 801 802 let* result = make_http_request ~method_:"GET" ~headers ~body:"" download_uri in 803 Lwt.return result 804end