diff --git a/dune-project b/dune-project index 42ce0db75..5557cd264 100644 --- a/dune-project +++ b/dune-project @@ -1,4 +1,4 @@ (lang dune 2.3) (name learn-ocaml) -(version 0.12) +(version 0.13.0) (allow_approximate_merlin) diff --git a/learn-ocaml-client.opam b/learn-ocaml-client.opam index 19db6a0c9..450b183d2 100644 --- a/learn-ocaml-client.opam +++ b/learn-ocaml-client.opam @@ -1,6 +1,6 @@ opam-version: "2.0" name: "learn-ocaml-client" -version: "0.12" +version: "0.13.0" authors: [ "Benjamin Canou (OCamlPro)" "Çağdaş Bozman (OCamlPro)" diff --git a/learn-ocaml-client.opam.locked b/learn-ocaml-client.opam.locked index c900f21ed..addad45fe 100644 --- a/learn-ocaml-client.opam.locked +++ b/learn-ocaml-client.opam.locked @@ -1,6 +1,6 @@ opam-version: "2.0" name: "learn-ocaml-client" -version: "0.12" +version: "0.13.0" synopsis: "The learn-ocaml client" description: """\ This contains the binaries to interact with the learn-ocaml diff --git a/learn-ocaml.opam b/learn-ocaml.opam index 4e3173000..27aad57af 100644 --- a/learn-ocaml.opam +++ b/learn-ocaml.opam @@ -1,6 +1,6 @@ opam-version: "2.0" name: "learn-ocaml" -version: "0.12" +version: "0.13.0" authors: [ "Benjamin Canou (OCamlPro)" "Çağdaş Bozman (OCamlPro)" diff --git a/learn-ocaml.opam.locked b/learn-ocaml.opam.locked index 3426831e9..d26b1c174 100644 --- a/learn-ocaml.opam.locked +++ b/learn-ocaml.opam.locked @@ -1,6 +1,6 @@ opam-version: "2.0" name: "learn-ocaml" -version: "0.12" +version: "0.13.0" authors: [ "Benjamin Canou (OCamlPro)" "Çağdaş Bozman (OCamlPro)" diff --git a/src/main/learnocaml_client.ml b/src/main/learnocaml_client.ml index 49c88c284..1b83dfa5c 100644 --- a/src/main/learnocaml_client.ml +++ b/src/main/learnocaml_client.ml @@ -502,15 +502,37 @@ let upload_report server token ex solution report = (Token.to_string token) | e -> Lwt.fail e + (** [is_supported cached_version req] checks if the request is server-compat. + (and if the client Learnocaml_version.v >= the server_version) + + [is_supported (Some version) req] = Ok version, if it's compatible, + [is_supported (Some version) req] = Error message, if it's not, + [is_supported None req] = let v = GET Version in Ok v, if it's compatible, + [is_supported None req] = let v = GET Version in Error m, if it's not; + + and [is_supported None Version] will also do a GET Version request *) + let is_supported_server + : type resp. + Api.Compat.t option -> Uri.t -> resp Api.request -> (Api.Compat.t, string) result Lwt.t + = fun server_version server req -> + (match server_version with + | Some server_version -> Lwt.return server_version + | None -> + fetch server (Api.Version ()) >|= fun (server_version, _todo) -> + Api.Compat.v server_version) >|= fun server_version -> + match Api.is_supported ~server:server_version req with + | Ok () -> Ok server_version + | Error msg -> Error msg + let check_server_version ?(allow_static=false) server = Lwt.catch (fun () -> - fetch server (Api.Version ()) >|= fun (server_version,_) -> - if server_version <> Api.version then - (Printf.eprintf "API version mismatch: client v.%s and server v.%s\n" - Api.version server_version; - exit 1) - else - true) + is_supported_server + None (* if need be: Implement some server_version cache *) + server + (Api.Version ()) (* TODO: pass more precise requests *) + >|= function + | Ok _server_version -> true + | Error msg -> Printf.eprintf "%s\n" msg; exit 1) @@ fun e -> if not allow_static then begin diff --git a/src/state/learnocaml_api.ml b/src/state/learnocaml_api.ml index 63043e4c3..a0dbd1f81 100644 --- a/src/state/learnocaml_api.ml +++ b/src/state/learnocaml_api.ml @@ -10,6 +10,87 @@ open Learnocaml_data let version = Learnocaml_version.v +module type COMPAT = sig + (** List-based versions endowed with a lexicographic order. *) + type t + + val to_string : t -> string + + (** Supported formats: [Compat.v "str"] where "str" is + either "n", "-n" (a signed integer), or "n.str". + However, [Compat.v "0.14.rc1"] or so is not supported for now. *) + val v : string -> t + + (** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0") + are equal compats. But (v "1") is higher than (v "1.-1"), itself + higher than (v "1.-2"), and so on. *) + val le : t -> t -> bool + + val eq : t -> t -> bool + + val lt : t -> t -> bool + + type pred = + | Since of t | Upto of t | And of pred * pred + + val compat : pred -> t -> bool +end + +module Compat: COMPAT = struct + + (** List-based versions endowed with a lexicographic order. *) + type t = int list + + let to_string = function + | [] -> failwith "Compat.to_string" + | n :: l -> + List.fold_left (fun r e -> r ^ "." ^ string_of_int e) (string_of_int n) l + + (** Supported formats: [Compat.v "str"] where "str" is nonempty and + either "n", "-n" (a signed integer), or "n.str". + However, [Compat.v "0.14.rc1"] or so is not supported for now. *) + let v = function + | "" -> failwith "Compat.of_string" + | s -> String.split_on_char '.' s |> List.map int_of_string + + (** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0") + are equal versions. But (v "1") is higher than (v "1.-1"), itself + higher than (v "1.-2"), and so on. *) + let rec le v1 v2 = match v1, v2 with + | [], [] -> true + | [], 0 :: l2 -> le [] l2 + | [], n2 :: _ -> 0 < n2 + | 0 :: l1, [] -> le l1 [] + | n1 :: _, [] -> n1 < 0 + | n1 :: l1, n2 :: l2 -> n1 < n2 || (n1 = n2 && le l1 l2) + + let eq v1 v2 = le v1 v2 && le v2 v1 + + let lt v1 v2 = not (le v2 v1) + + type pred = + | Since of t (** >= v0 *) + | Upto of t (** < v1 *) + | And of pred * pred + + let rec compat pred v = + match pred with + | Since v0 -> le v0 v + | Upto v1 -> lt v v1 + | And (pred1, pred2) -> compat pred1 v && compat pred2 v + +end + +(* Tests +assert Compat.(le (v "0.12") (v "0.13.0"));; +assert Compat.(le (v "0.13.0") (v "0.13.1"));; +assert Compat.(le (v "0.13.1") (v "0.14.0"));; +assert Compat.(le (v "0.14.0") (v "1.0.0"));; +assert Compat.(le (v "1.1.1") (v "1.1.1"));; +assert Compat.(le (v "0.2") (v "0.10"));; +assert Compat.(le (v "1.9.5") (v "1.10.0"));; + *) + type _ request = | Static: string list -> string request @@ -69,6 +150,48 @@ type _ request = | Invalid_request: string -> string request +let supported_versions + : type resp. resp request -> Compat.pred + = function + | Static _ + | Version _ + | Nonce _ + | Create_token (_, _, _) + | Create_teacher_token _ + | Fetch_save _ + | Archive_zip _ + | Update_save (_, _) + | Git (_, _) + | Students_list _ + | Set_students_list (_, _) + | Students_csv (_, _, _) + | Exercise_index _ + | Exercise (_, _) + | Lesson_index _ + | Lesson _ + | Tutorial_index _ + | Tutorial _ + | Playground_index _ + | Playground _ + | Exercise_status_index _ + | Exercise_status (_, _) + | Set_exercise_status (_, _) + | Partition (_, _, _, _) + | Invalid_request _ -> Compat.(Since (v "0.12")) + +let is_supported + : type resp. ?current:Compat.t -> server:Compat.t -> resp request -> + (unit, string) result = + fun ?(current = Compat.v Learnocaml_version.v) ~server request -> + let supp = supported_versions request in + if Compat.(compat (Since server) current) (* server <= current *) + && Compat.compat supp current (* request supported by current codebase *) + && Compat.compat supp server (* request supported by server *) + then Ok () else + Error (Printf.sprintf + {|API request not supported by server v.%s using client v.%s|} + (* NOTE: we may want to add some string_of_request call as well *) + (Compat.to_string server) (Compat.to_string current)) type http_request = { meth: [ `GET | `POST of string]; diff --git a/src/state/learnocaml_api.mli b/src/state/learnocaml_api.mli index 2d977d3d0..36972ef6a 100644 --- a/src/state/learnocaml_api.mli +++ b/src/state/learnocaml_api.mli @@ -23,6 +23,60 @@ open Learnocaml_data val version: string +module type COMPAT = sig + (** List-based versions endowed with a lexicographic order. *) + type t + + val to_string : t -> string + + (** Supported formats: [Compat.v "str"] where "str" is + either "n", "-n" (a signed integer), or "n.str". + However, [Compat.v "0.14.rc1"] or so is not supported for now. *) + val v : string -> t + + (** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0") + are equal versions. But (v "1") is higher than (v "1.-1"), itself + higher than (v "1.-2"), and so on. *) + val le : t -> t -> bool + + val eq : t -> t -> bool + + val lt : t -> t -> bool + + type pred = + | Since of t | Upto of t | And of pred * pred + + val compat : pred -> t -> bool +end + +module Compat: COMPAT + +(** Note about backward-compatibility: + +The architecture of learn-ocaml merges the (client, server) components +in the same codebase, so it's easier to update both of them in one go. + +But this tight coupling meant that a learn-ocaml-client version would +only be compatible with a single server version, hence a frequent but +annoying error "API version mismatch: client v._ and server v._". + +So since learn-ocaml 0.13, a given client_version will try to be +compatible with as much server_version's as possible (>= 0.12 & +<= client_version). + +To this aim, each [request] constructor is annotated with a version +constraint of type [Compat.t], see [supported_versions]. + +Regarding the inevitable extensions of the API: + +- make sure one only adds constructors to this [request] type, +- and that their semantics does not change + (or at least in a backward-compatible way; + see PR https://github.com/ocaml-sf/learn-ocaml/pull/397 + for a counter-example) +- but if a given entrypoint would need to be removed, + rather add a Compat.Upto (*<*) constraint. + *) type _ request = | Static: string list -> string request @@ -90,6 +144,14 @@ type _ request = (** Only for server-side handling: bound to requests not matching any case above *) +val supported_versions: 'a request -> Compat.pred + +(** [is supported client server req] = Ok () if + [server <= client && current "supports" req && server "supports" client] *) +val is_supported: + ?current:Compat.t -> server:Compat.t -> + 'resp request -> (unit, string) result + type http_request = { meth: [ `GET | `POST of string]; path: string list;