Revision 9b9eabddd2481575f85443267760777f9c62d808 authored by Lucas Randazzo on 27 February 2024, 16:41:35 UTC, committed by Marge Bot on 06 March 2024, 12:34:23 UTC
1 parent 83d289c
Raw File
client_rpc_commands.ml
(*****************************************************************************)
(*                                                                           *)
(* Open Source License                                                       *)
(* Copyright (c) 2018 Dynamic Ledger Solutions, Inc. <contact@tezos.com>     *)
(*                                                                           *)
(* Permission is hereby granted, free of charge, to any person obtaining a   *)
(* copy of this software and associated documentation files (the "Software"),*)
(* to deal in the Software without restriction, including without limitation *)
(* the rights to use, copy, modify, merge, publish, distribute, sublicense,  *)
(* and/or sell copies of the Software, and to permit persons to whom the     *)
(* Software is furnished to do so, subject to the following conditions:      *)
(*                                                                           *)
(* The above copyright notice and this permission notice shall be included   *)
(* in all copies or substantial portions of the Software.                    *)
(*                                                                           *)
(* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR*)
(* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,  *)
(* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL   *)
(* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER*)
(* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING   *)
(* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER       *)
(* DEALINGS IN THE SOFTWARE.                                                 *)
(*                                                                           *)
(*****************************************************************************)

(* Tezos Command line interface - Generic JSON RPC interface *)

open Tezos_clic
open Json_schema

(*-- Assisted, schema directed input fill in --------------------------------*)

exception Unsupported_construct

type input = {
  int : int -> int -> string option -> string list -> int Lwt.t;
  float : string option -> string list -> float Lwt.t;
  string : string option -> string list -> string Lwt.t;
  bool : string option -> string list -> bool Lwt.t;
  continue : string option -> string list -> bool Lwt.t;
  display : string -> unit Lwt.t;
}

let no_service_at_valid_prefix (cctxt : #Client_context.full) =
  cctxt#error "No service found at this URL (but this is a valid prefix)\n"

(* generic JSON generation from a schema with callback for random or
   interactive filling *)
let fill_in ?(show_optionals = true) input schema =
  let rec element path {title; kind; _} =
    let open Lwt_syntax in
    match kind with
    | Integer {minimum; maximum; _} ->
        let minimum =
          match minimum with
          | None -> min_int
          | Some (m, `Inclusive) -> int_of_float m
          | Some (m, `Exclusive) -> int_of_float m + 1
        in
        let maximum =
          match maximum with
          | None -> max_int
          | Some (m, `Inclusive) -> int_of_float m
          | Some (m, `Exclusive) -> int_of_float m - 1
        in
        let+ i = input.int minimum maximum title path in
        `Float (float i)
    | Number _ ->
        let+ f = input.float title path in
        `Float f
    | Boolean ->
        let+ f = input.bool title path in
        `Bool f
    | String _ ->
        let+ f = input.string title path in
        `String f
    | Combine ((One_of | Any_of), elts) ->
        let nb = List.length elts in
        let* n =
          input.int 0 (nb - 1) (Some "Select the schema to follow") path
        in
        element path (WithExceptions.Option.get ~loc:__LOC__ @@ List.nth elts n)
    | Combine ((All_of | Not), _) -> Lwt.fail Unsupported_construct
    | Def_ref name ->
        Lwt.return (`String (Json_query.json_pointer_of_path name))
    | Id_ref _ | Ext_ref _ -> Lwt.fail Unsupported_construct
    | Array (elts, _) ->
        let+ a =
          List.mapi_s (fun n elt -> element (string_of_int n :: path) elt) elts
        in
        `A a
    | Object {properties; _} ->
        if show_optionals then
          let+ o =
            List.map_s
              (fun (n, elt, _, _) ->
                let+ json = element (n :: path) elt in
                (n, json))
              properties
          in
          `O o
        else
          let+ o =
            List.filter_map_s
              (fun (n, elt, optional, _) ->
                if optional then
                  let+ json = element (n :: path) elt in
                  Some (n, json)
                else Lwt.return_none)
              properties
          in
          `O o
    | Monomorphic_array (elt, specs) ->
        let rec fill_loop acc min n max =
          if n > max then Lwt.return acc
          else
            let* json = element (string_of_int n :: path) elt in
            let* b =
              if n < min then Lwt.return_true else input.continue title path
            in
            if b then fill_loop (json :: acc) min (succ n) max
            else Lwt.return (json :: acc)
        in
        let max = Option.value specs.max_items ~default:max_int in
        let+ a = fill_loop [] specs.min_items 0 max in
        let a = List.rev a in
        `A a
    | Any -> Lwt.fail Unsupported_construct
    | Dummy -> Lwt.fail Unsupported_construct
    | Null -> Lwt.return `Null
  in
  element [] (Json_schema.root schema)

let random_fill_in ?(show_optionals = true) schema =
  let display _ = Lwt.return_unit in
  let int min max _ _ =
    let max = Int64.of_int max and min = Int64.of_int min in
    let range = Int64.sub max min in
    let random_int64 = Int64.add (Random.int64 range) min in
    Lwt.return (Int64.to_int random_int64)
  in
  let string _title _ = Lwt.return "" in
  let float _ _ = Lwt.return (Random.float infinity) in
  let bool _ _ = Lwt.return (Random.int 2 = 0) in
  let continue _ _ = Lwt.return (Random.int 4 = 0) in
  Lwt.catch
    (fun () ->
      Lwt_result.ok
      @@ fill_in
           ~show_optionals
           {int; float; string; bool; display; continue}
           schema)
    (fun e ->
      let msg = Printf.sprintf "Fill-in failed %s\n%!" (Printexc.to_string e) in
      Lwt.return_error msg)

let editor_fill_in ?(show_optionals = true) schema =
  let tmp = Filename.temp_file "tezos_rpc_call_" ".json" in
  let open Lwt_syntax in
  let rec init () =
    (* write a temp file with instructions *)
    let* r = random_fill_in ~show_optionals schema in
    match r with
    | Error msg -> Lwt.return_error msg
    | Ok json ->
        let* () =
          Lwt_io.(
            with_file ~mode:Output tmp (fun fp ->
                write_line fp (Data_encoding.Json.to_string json)))
        in
        edit ()
  and edit () =
    (* launch the user's editor on it *)
    let editor_cmd =
      let ed =
        match (Sys.getenv_opt "EDITOR", Sys.getenv_opt "VISUAL") with
        | Some ed, _ -> ed
        | None, Some ed -> ed
        | None, None when Sys.win32 ->
            (* TODO: I have no idea what I'm doing here *)
            "notepad.exe"
        | _ ->
            (* TODO: vi on MacOSX ? *)
            "nano"
      in
      Lwt_process.shell (ed ^ " " ^ tmp)
    in
    let* s = (Lwt_process.open_process_none editor_cmd)#status in
    match s with
    | Unix.WEXITED 0 ->
        let* json = reread () in
        let* () = delete () in
        Lwt.return json
    | Unix.WSIGNALED x | Unix.WSTOPPED x | Unix.WEXITED x ->
        let msg = Printf.sprintf "FAILED %d \n%!" x in
        let* () = delete () in
        Lwt.return_error msg
  and reread () =
    (* finally reread the file *)
    let* text = Lwt_io.(with_file ~mode:Input tmp (fun fp -> read fp)) in
    match Data_encoding.Json.from_string text with
    | Ok r -> Lwt.return_ok r
    | Error msg -> Lwt.return_error (Format.asprintf "bad input: %s" msg)
  and delete () =
    (* and delete the temp file *)
    Lwt_unix.unlink tmp
  in
  init ()

(*-- Nice list display ------------------------------------------------------*)

let rec count =
  let open Tezos_rpc.Description in
  function
  | Empty -> 0
  | Dynamic _ -> 1
  | Static {services; subdirs} ->
      let service = Tezos_rpc.Service.MethMap.cardinal services in
      let subdirs =
        match subdirs with
        | None -> 0
        | Some (Suffixes subdirs) ->
            Resto.StringMap.fold (fun _ t r -> r + count t) subdirs 0
        | Some (Arg (_, subdir)) -> count subdir
      in
      service + subdirs

(*-- Commands ---------------------------------------------------------------*)

let list url (cctxt : #Client_context.full) =
  let open Lwt_result_syntax in
  let args = String.split_no_empty '/' url in
  let* tree = Tezos_rpc.Description.describe cctxt ~recurse:true args in
  let open Tezos_rpc.Description in
  let collected_args = ref [] in
  let collect arg =
    if
      not
        (arg.Tezos_rpc.Arg.descr <> None
        && List.mem ~equal:Tezos_rpc.Arg.eq_descr arg !collected_args)
    then collected_args := arg :: !collected_args
  in
  let display_paragraph ppf description =
    Format.fprintf
      ppf
      "@,    @[%a@]"
      (fun ppf words -> List.iter (Format.fprintf ppf "%s@ ") words)
      (String.split_no_empty ' ' description)
  in
  let display_arg ppf arg =
    match arg.Tezos_rpc.Arg.descr with
    | None -> Format.fprintf ppf "%s" arg.Tezos_rpc.Arg.name
    | Some descr ->
        Format.fprintf
          ppf
          "<%s>%a"
          arg.Tezos_rpc.Arg.name
          display_paragraph
          descr
  in
  let display_service ppf (_path, tpath, service) =
    Format.fprintf
      ppf
      "- %s /%s"
      (Tezos_rpc.Service.string_of_meth service.meth)
      (String.concat "/" tpath) ;
    match service.description with
    | None | Some "" -> ()
    | Some description -> display_paragraph ppf description
  in
  let display_services ppf (_path, tpath, services) =
    Format.pp_print_list
      (fun ppf (_, s) -> display_service ppf (_path, tpath, s))
      ppf
      (Tezos_rpc.Service.MethMap.bindings services)
  in
  let rec display ppf (path, tpath, tree) =
    match tree with
    | Dynamic description -> (
        Format.fprintf ppf "- /%s <dynamic>" (String.concat "/" tpath) ;
        match description with
        | None | Some "" -> ()
        | Some description -> display_paragraph ppf description)
    | Empty -> ()
    | Static {services; subdirs = None} ->
        display_services ppf (path, tpath, services)
    | Static {services; subdirs = Some (Suffixes subdirs)} -> (
        match
          ( Tezos_rpc.Service.MethMap.cardinal services,
            Resto.StringMap.bindings subdirs )
        with
        | 0, [] -> ()
        | 0, [(n, solo)] -> display ppf (path @ [n], tpath @ [n], solo)
        | _, items when count tree >= 3 && path <> [] ->
            Format.fprintf
              ppf
              "@[<v 2>+ %s/@,%a@]"
              (String.concat "/" path)
              (display_list tpath)
              items
        | _, items when count tree >= 3 && path <> [] ->
            Format.fprintf
              ppf
              "@[<v 2>+ %s@,%a@,%a@]"
              (String.concat "/" path)
              display_services
              (path, tpath, services)
              (display_list tpath)
              items
        | 0, (n, t) :: items ->
            Format.fprintf ppf "%a" display (path @ [n], tpath @ [n], t) ;
            List.iter
              (fun (n, t) ->
                Format.fprintf ppf "@,%a" display (path @ [n], tpath @ [n], t))
              items
        | _, items ->
            display_services ppf (path, tpath, services) ;
            List.iter
              (fun (n, t) ->
                Format.fprintf ppf "@,%a" display (path @ [n], tpath @ [n], t))
              items)
    | Static {services; subdirs = Some (Arg (arg, solo))}
      when Tezos_rpc.Service.MethMap.cardinal services = 0 ->
        collect arg ;
        let name = Printf.sprintf "<%s>" arg.Tezos_rpc.Arg.name in
        display ppf (path @ [name], tpath @ [name], solo)
    | Static {services; subdirs = Some (Arg (arg, solo))} ->
        collect arg ;
        display_services ppf (path, tpath, services) ;
        Format.fprintf ppf "@," ;
        let name = Printf.sprintf "<%s>" arg.Tezos_rpc.Arg.name in
        display ppf (path @ [name], tpath @ [name], solo)
  and display_list tpath =
    Format.pp_print_list (fun ppf (n, t) -> display ppf ([n], tpath @ [n], t))
  in
  let*! () =
    cctxt#message
      "@ @[<v 2>Available services:@ @ %a@]@."
      display
      (args, args, tree)
  in
  if !collected_args <> [] then
    let*! () =
      cctxt#message
        "@,@[<v 2>Dynamic parameter description:@ @ %a@]@."
        (Format.pp_print_list display_arg)
        !collected_args
    in
    return_unit
  else return_unit

let schema meth url (cctxt : #Client_context.full) =
  let open Lwt_result_syntax in
  let args = String.split_no_empty '/' url in
  let open Tezos_rpc.Description in
  let* s = Tezos_rpc.Description.describe cctxt ~recurse:false args in
  match s with
  | Static {services; _} -> (
      match Tezos_rpc.Service.MethMap.find_opt meth services with
      | None -> no_service_at_valid_prefix cctxt
      | Some {input = Some input; output; _} ->
          let json =
            `O
              [
                ("input", Json_schema.to_json (fst (Lazy.force input)));
                ("output", Json_schema.to_json (fst (Lazy.force output)));
              ]
          in
          let*! () = cctxt#message "%a" Json_repr.(pp (module Ezjsonm)) json in
          return_unit
      | Some {input = None; output; _} ->
          let json =
            `O [("output", Json_schema.to_json (fst (Lazy.force output)))]
          in
          let*! () = cctxt#message "%a" Json_repr.(pp (module Ezjsonm)) json in
          return_unit)
  | _ -> no_service_at_valid_prefix cctxt

let format binary meth url (cctxt : #Client_context.io_rpcs) =
  let open Lwt_result_syntax in
  let args = String.split_no_empty '/' url in
  let open Tezos_rpc.Description in
  let pp =
    if binary then fun ppf (_, schema) ->
      Data_encoding.Binary_schema.pp ppf schema
    else fun ppf (schema, _) -> Json_schema.pp ppf schema
  in
  let* s = Tezos_rpc.Description.describe cctxt ~recurse:false args in
  match s with
  | Static {services; _} -> (
      match Tezos_rpc.Service.MethMap.find_opt meth services with
      | None -> no_service_at_valid_prefix cctxt
      | Some {input = Some input; output; _} ->
          let*! () =
            cctxt#message
              "@[<v 0>@[<v 2>Input format:@,\
               %a@]@,\
               @[<v 2>Output format:@,\
               %a@]@,\
               @]"
              pp
              (Lazy.force input)
              pp
              (Lazy.force output)
          in
          return_unit
      | Some {input = None; output; _} ->
          let*! () =
            cctxt#message
              "@[<v 0>@[<v 2>Output format:@,%a@]@,@]"
              pp
              (Lazy.force output)
          in
          return_unit)
  | _ -> no_service_at_valid_prefix cctxt

let fill_in ?(show_optionals = true) schema =
  let open Json_schema in
  match (root schema).kind with
  | Null -> Lwt.return_ok `Null
  | Any | Object {properties = []; _} -> Lwt.return_ok (`O [])
  | _ -> editor_fill_in ~show_optionals schema

let display_answer (cctxt : #Client_context.full) :
    Tezos_rpc.Context.generic_call_result -> unit Lwt.t = function
  | `Json (`Ok json) -> cctxt#answer "%a" Json_repr.(pp (module Ezjsonm)) json
  | `Binary (`Ok binary) -> cctxt#answer "%a" Hex.pp (Hex.of_string binary)
  | `Json (`Error (Some error)) ->
      cctxt#error
        "@[<v 2>Command failed: @[%a@]@]@."
        (Format.pp_print_list Error_monad.pp)
        (Data_encoding.Json.destruct
           (Data_encoding.list Error_monad.error_encoding)
           error)
  | `Binary (`Error (Some error)) -> (
      match Data_encoding.Binary.of_string Error_monad.trace_encoding error with
      | Ok trace ->
          cctxt#error
            "@[<v 2>Command failed: @[%a@]@]@."
            Error_monad.pp_print_trace
            trace
      | Error msg ->
          cctxt#error
            "@[<v 2>Error whilst decoding the server response: @[%a@]@]@."
            Data_encoding.Binary.pp_read_error
            msg)
  | `Json (`Not_found _) | `Binary (`Not_found _) | `Other (_, `Not_found _) ->
      cctxt#error "No service found at this URL\n%!"
  | `Json (`Gone _) | `Binary (`Gone _) | `Other (_, `Gone _) ->
      cctxt#error
        "Requested data concerns a pruned block and target resource is no \
         longer available\n\
         %!"
  | `Json (`Unauthorized _)
  | `Binary (`Unauthorized _)
  | `Other (_, `Unauthorized _) ->
      cctxt#error "@[<v 2>[HTTP 403] Access denied to: %a@]@." Uri.pp cctxt#base
  | _ -> cctxt#error "Unexpected server answer\n%!"

let call ?body meth raw_url (cctxt : #Client_context.full) =
  let open Lwt_result_syntax in
  let uri = Uri.of_string raw_url in
  let args = String.split_no_empty '/' (Uri.path uri) in
  if not cctxt#verbose_rpc_error_diagnostics then
    let body =
      (* This code is similar to a piece of code in [fill_in]
         function. An RPC is declared as POST, PATCH or PUT, but the
         body is not given. In that case, the body should be an empty
         JSON object. *)
      match (meth, body) with
      | _, Some _ -> body
      | `DELETE, None | `GET, None -> None
      | `PATCH, None | `PUT, None | `POST, None -> Some (`O [])
    in
    let* answer = cctxt#generic_media_type_call meth ?body uri in
    let*! () = display_answer cctxt answer in
    return_unit
  else
    let* s = Tezos_rpc.Description.describe cctxt ~recurse:false args in
    match s with
    | Static {services; _} -> (
        match Tezos_rpc.Service.MethMap.find_opt meth services with
        | None -> no_service_at_valid_prefix cctxt
        | Some {input = None; _} ->
            let*! () =
              match body with
              | None -> Lwt.return_unit
              | Some _ ->
                  cctxt#warning
                    "This URL did not expect a JSON input but one was provided\n\
                     %!"
            in
            let* answer = cctxt#generic_media_type_call meth ?body uri in
            let*! () = display_answer cctxt answer in
            return_unit
        | Some {input = Some input; _} -> (
            let*! r =
              match body with
              | None -> fill_in ~show_optionals:false (fst (Lazy.force input))
              | Some body -> Lwt.return (Ok body)
            in
            match r with
            | Error msg -> cctxt#error "%s" msg
            | Ok body ->
                let* answer = cctxt#generic_media_type_call meth ~body uri in
                let*! () = display_answer cctxt answer in
                return_unit))
    | _ -> cctxt#error "No service found at this URL\n%!"

let call_with_json meth raw_url json (cctxt : #Client_context.full) =
  match Data_encoding.Json.from_string json with
  | exception Assert_failure _ ->
      (* Ref : https://github.com/mirage/ezjsonm/issues/31 *)
      cctxt#error "Failed to parse the provided json: unwrapped JSON value.\n%!"
  | Error err -> cctxt#error "Failed to parse the provided json: %s\n%!" err
  | Ok body -> call meth ~body raw_url cctxt

let call_with_file_or_json meth url maybe_file (cctxt : #Client_context.full) =
  let open Lwt_result_syntax in
  let* json =
    match TzString.split ':' ~limit:1 maybe_file with
    | ["file"; filename] ->
        Lwt.catch
          (fun () ->
            Lwt_result.ok @@ Lwt_io.(with_file ~mode:Input filename read))
          (fun exn -> failwith "cannot read file (%s)" (Printexc.to_string exn))
    | _ -> return maybe_file
  in
  call_with_json meth url json cctxt

let meth_params ?(name = "HTTP method") ?(desc = "") params =
  param
    ~name
    ~desc
    (parameter
       ~autocomplete:(fun _ ->
         Lwt.return_ok
         @@ List.map String.lowercase_ascii
         @@ List.map Resto.string_of_meth
         @@ [`GET; `POST; `DELETE; `PUT; `PATCH])
       (fun _ name ->
         let open Lwt_result_syntax in
         match Resto.meth_of_string (String.uppercase_ascii name) with
         | None -> failwith "Unknown HTTP method: %s" name
         | Some meth -> return meth))
    params

let group =
  {Tezos_clic.name = "rpc"; title = "Commands for the low level RPC layer"}

let commands =
  [
    command
      ~group
      ~desc:
        "List RPCs under a given URL prefix.\n\
         Some parts of the RPC service hierarchy depend on parameters,\n\
         they are marked by a suffix `<dynamic>`.\n\
         You can list these sub-hierarchies by providing a concrete URL prefix \
         whose arguments are set to a valid value."
      no_options
      (prefixes ["rpc"; "list"]
      @@ string ~name:"url" ~desc:"the URL prefix"
      @@ stop)
      (fun () -> list);
    command
      ~group
      ~desc:"Alias to `rpc list /`."
      no_options
      (prefixes ["rpc"; "list"] @@ stop)
      (fun () -> list "/");
    command
      ~group
      ~desc:"Get the input and output JSON schemas of an RPC."
      no_options
      (prefixes ["rpc"; "schema"]
      @@ meth_params
      @@ string ~name:"url" ~desc:"the RPC url"
      @@ stop)
      (fun () -> schema);
    command
      ~group
      ~desc:"Get the humanoid readable input and output formats of an RPC."
      (args1 (switch ~doc:"Binary format" ~short:'b' ~long:"binary" ()))
      (prefixes ["rpc"; "format"]
      @@ meth_params
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ stop)
      format;
    command
      ~group
      ~desc:"Call an RPC with the GET method."
      no_options
      (prefixes ["rpc"; "get"] @@ string ~name:"url" ~desc:"the RPC URL" @@ stop)
      (fun () -> call `GET);
    command
      ~group
      ~desc:
        "Call an RPC with the POST method.\n\
         It invokes $EDITOR if input data is needed."
      no_options
      (prefixes ["rpc"; "post"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ stop)
      (fun () -> call `POST);
    command
      ~group
      ~desc:
        "Call an RPC with the POST method, providing input data via the \
         command line."
      no_options
      (prefixes ["rpc"; "post"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ prefix "with"
      @@ string
           ~name:"input"
           ~desc:
             "the raw JSON input to the RPC\n\
              For instance, use `{}` to send the empty document.\n\
              Alternatively, use `file:path` to read the JSON data from a file."
      @@ stop)
      (fun () -> call_with_file_or_json `POST);
    command
      ~group
      ~desc:
        "Call an RPC with the PATCH method.\n\
         It invokes $EDITOR if input data is needed."
      no_options
      (prefixes ["rpc"; "patch"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ stop)
      (fun () -> call `PATCH);
    command
      ~group
      ~desc:
        "Call an RPC with the PATCH method, providing input data via the \
         command line."
      no_options
      (prefixes ["rpc"; "patch"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ prefix "with"
      @@ string
           ~name:"input"
           ~desc:
             "the raw JSON input to the RPC\n\
              For instance, use `{}` to send the empty document.\n\
              Alternatively, use `file:path` to read the JSON data from a file."
      @@ stop)
      (fun () -> call_with_file_or_json `PATCH);
    command
      ~group
      ~desc:
        "Call an RPC with the PUT method.\n\
         It invokes $EDITOR if input data is needed."
      no_options
      (prefixes ["rpc"; "put"] @@ string ~name:"url" ~desc:"the RPC URL" @@ stop)
      (fun () -> call `PUT);
    command
      ~group
      ~desc:
        "Call an RPC with the PUT method, providing input data via the command \
         line."
      no_options
      (prefixes ["rpc"; "put"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ prefix "with"
      @@ string
           ~name:"input"
           ~desc:
             "the raw JSON input to the RPC\n\
              For instance, use `{}` to send the empty document.\n\
              Alternatively, use `file:path` to read the JSON data from a file."
      @@ stop)
      (fun () -> call_with_file_or_json `PUT);
    command
      ~group
      ~desc:"Call an RPC with the DELETE method."
      no_options
      (prefixes ["rpc"; "delete"]
      @@ string ~name:"url" ~desc:"the RPC URL"
      @@ stop)
      (fun () -> call `DELETE);
  ]
back to top