Generic Module Signatures

I am currently trying to write an ocaml package to send API requests for a given service. For each endpoint I wish to handle, I produce a module with a record type defining the fields which must be supplied. I then use yojson_of_t, t_of_yojson to handle these. In addition, I have a make function which allows the user to produce the relevant type, for example:

module Body = struct
  type t = { item_a: string; item_b: string } [@@deriving yojson_of]

  let make ~item_a ~item_b () = { item_a; item_b }
end

As each of the endpoints requires a different body, I have multiple implementations of body – one for each service – the type t is a different record type, and the corresponding make function changes accordingly.

I wish to produce a signature which holds for each body, so I can write:

module EndpointA_Body : BodySignature = struct ... end
module EndpointB_Body : BodySignature = struct ... end

My current understanding of module signatures implies that this is not possible – is this true?

If the values which differ in type as regards your various endpoints are known when you apply your make function to construct the endpoints, you can consider storing the values as closures, or store them existentially using GADTs or first class modules. A simple example is here: OO with rere? how to work-around? - #18 by cvine and in related postings in that thread.

Also, don’t forget variants: you could consider representing each different record comprising your endpoints as the payload of the cases of a variant type and match on the variant. The best approach depends on the trade offs.

You are correct in that it is not possible to define a generic signature for the Endpoint bodies that also defines the signature for make but the right solution depends on what you are trying to achieve.

If the bodies/implementation for the Endpoints are given already (maybe they are auto-generated), you may not need to specify a signature for the implementation. However, It can be useful to define a common module signature to allow defining helper functions to e.g. making a request. For example:

module EndpointA = struct 
  type t = { item_a: string; item_b: string } [@@deriving yojson_of]
  let make ~item_a ~item_b () = { item_a; item_b } 
 end

module type Endpoint = sig
  type t [@@deriving yojson_of]
end

let call_endpoint (type a) (module E : Endpoint with type t = a) (t : a) = 
  ...
  (* Assuming there is a generic function that can send the request to a specific endpoint with the signature: send_request: Yojson.Basic.t -> unit  *)
  let () = send_request (E.t_to_yojson t) in
  ...

(* Call the endpoint *)
let message = EndpointA.make ~item_a:"Hello" ~item_b:"World" () in
let _ = call_endpoint (module EndpointA) message

As the implementation of call_endpoint does not need to call Endpoint.make, there is no need to define it in the the Endpoint module signature. The only drawback is that if Endpoints are not a superset of the Endpoint signature the error location will not be at module definition point, but where its used.

1 Like

That makes perfect sense, thank you for clarifying with a nice example.

I still need a better understanding of first-class modules, but this is a nice demonstration

I ended up with something like this:

module type BodyGeneric = sig
  type t [@@deriving yojson]
end

module type HeaderGeneric = sig
  type t [@@deriving yojson]
end

module type ResponseGeneric = sig
  type t [@@deriving yojson]
end

let header_to_cohttp (type a) (module H : HeaderGeneric with type t = a) (t : a) =
  Utils.cohttp_of_header H.yojson_of_t t
;;

let body_to_cohttp (type a) (module B : BodyGeneric with type t = a) (t : a) =
  Utils.cohttp_of_body B.yojson_of_t t
;;

let to_cohttp
  (type a b)
  (module H : HeaderGeneric with type t = a)
  (module B : BodyGeneric with type t = b)
  (headers : a)
  (body : b)
  =
  let headers = header_to_cohttp (module H) headers in
  let body = body_to_cohttp (module B) body in
  headers, body
;;

let make_request
  (type h b r)
  (module H : HeaderGeneric with type t = h)
  (module B : BodyGeneric with type t = b)
  (module R : ResponseGeneric with type t = r)
  headers
  body
  uri
  =
  Rest.make_request (Post (headers, body)) uri R.t_of_yojson
;;

module type RequestGeneric = sig
  type h
  type b
  type r

  val r_to_cohttp : h -> b -> Cohttp.Header.t * Cohttp_lwt.Body.t

  val r_make_request
    :  Cohttp.Header.t
    -> Cohttp_lwt.Body.t
    -> Uri.t
    -> (r, Bad_response.t) result Lwt.t
end

let make_request_module
  (type x y z)
  (module H : HeaderGeneric with type t = x)
  (module B : BodyGeneric with type t = y)
  (module R : ResponseGeneric with type t = z)
  =
  let module RR = struct
    type h = H.t
    type b = B.t
    type r = R.t

    let r_to_cohttp headers body = to_cohttp (module H) (module B) headers body

    let r_make_request headers body uri =
      make_request (module H) (module B) (module R) headers body uri
    ;;
  end
  in
  (module RR : RequestGeneric with type h = x and type b = y and type r = z)
;;

module ITIRequest =
  (val make_request_module
         (module Text_to_image.HeaderImpl)
         (module Text_to_image.BodyImpl)
         (module Generation.Response))

This seems to work as expected, but I have just one small issue: the exposed types are now of ITIRequest.h, ITIRequest.b, ITIRequest.r, i.e: the module signature for ITIRequest is:

sig
  type h = ITIRequest.h
  type b = ITIRequest.b
  type r = ITIRequest.r

  val r_to_cohttp : h -> b -> Cohttp.Header.t * Cohttp_lwt.Body.t

  val r_make_request
    :  Cohttp.Header.t
    -> Cohttp_lwt.Body.t
    -> Uri.t
    -> (r, Bad_response.t) result Lwt.t
end

Would is be possible to make the exposed types be equal to the types of the module used to define the ITIRequest module?