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.
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?