Skip to content

Commit 713c19d

Browse files
committed
feat: Relax the client/server coupling & Add support for API versioning (ocaml-sf#426)
* chore: Bump release number to 0.13.0 (not 0.13) * Actually, for upcoming releases, a good practice could be to bump the version (say, the patchlevel part) after tagging the release. * feat: Add declarative Learnocaml_api.request versioning * BREAKING-CHANGE: to be reverted: Remove learn-ocaml-client.opam.locked
1 parent ce6ef6e commit 713c19d

File tree

4 files changed

+215
-7
lines changed

4 files changed

+215
-7
lines changed

dune-project

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
(lang dune 1.7)
22
(name learn-ocaml)
33
(version 0.15.0)
4+
(allow_approximate_merlin)

src/main/learnocaml_client.ml

+29-7
Original file line numberDiff line numberDiff line change
@@ -594,15 +594,37 @@ let upload_report server token ex solution report =
594594
(Token.to_string token)
595595
| e -> Lwt.fail e
596596

597+
(** [is_supported cached_version req] checks if the request is server-compat.
598+
(and if the client Learnocaml_version.v >= the server_version)
599+
600+
[is_supported (Some version) req] = Ok version, if it's compatible,
601+
[is_supported (Some version) req] = Error message, if it's not,
602+
[is_supported None req] = let v = GET Version in Ok v, if it's compatible,
603+
[is_supported None req] = let v = GET Version in Error m, if it's not;
604+
605+
and [is_supported None Version] will also do a GET Version request *)
606+
let is_supported_server
607+
: type resp.
608+
Api.Compat.t option -> Uri.t -> resp Api.request -> (Api.Compat.t, string) result Lwt.t
609+
= fun server_version server req ->
610+
(match server_version with
611+
| Some server_version -> Lwt.return server_version
612+
| None ->
613+
fetch server (Api.Version ()) >|= fun (server_version, _todo) ->
614+
Api.Compat.v server_version) >|= fun server_version ->
615+
match Api.is_supported ~server:server_version req with
616+
| Ok () -> Ok server_version
617+
| Error msg -> Error msg
618+
597619
let check_server_version ?(allow_static=false) server =
598620
Lwt.catch (fun () ->
599-
fetch server (Api.Version ()) >|= fun (server_version,_) ->
600-
if server_version <> Api.version then
601-
(Printf.eprintf "API version mismatch: client v.%s and server v.%s\n"
602-
Api.version server_version;
603-
exit 1)
604-
else
605-
true)
621+
is_supported_server
622+
None (* if need be: Implement some server_version cache *)
623+
server
624+
(Api.Version ()) (* TODO: pass more precise requests *)
625+
>|= function
626+
| Ok _server_version -> true
627+
| Error msg -> Printf.eprintf "%s\n" msg; exit 1)
606628
@@ fun e ->
607629
if not allow_static then
608630
begin

src/state/learnocaml_api.ml

+123
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,87 @@ open Learnocaml_data
1010

1111
let version = Learnocaml_version.v
1212

13+
module type COMPAT = sig
14+
(** List-based versions endowed with a lexicographic order. *)
15+
type t
16+
17+
val to_string : t -> string
18+
19+
(** Supported formats: [Compat.v "str"] where "str" is
20+
either "n", "-n" (a signed integer), or "n.str".
21+
However, [Compat.v "0.14.rc1"] or so is not supported for now. *)
22+
val v : string -> t
23+
24+
(** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0")
25+
are equal compats. But (v "1") is higher than (v "1.-1"), itself
26+
higher than (v "1.-2"), and so on. *)
27+
val le : t -> t -> bool
28+
29+
val eq : t -> t -> bool
30+
31+
val lt : t -> t -> bool
32+
33+
type pred =
34+
| Since of t | Upto of t | And of pred * pred
35+
36+
val compat : pred -> t -> bool
37+
end
38+
39+
module Compat: COMPAT = struct
40+
41+
(** List-based versions endowed with a lexicographic order. *)
42+
type t = int list
43+
44+
let to_string = function
45+
| [] -> failwith "Compat.to_string"
46+
| n :: l ->
47+
List.fold_left (fun r e -> r ^ "." ^ string_of_int e) (string_of_int n) l
48+
49+
(** Supported formats: [Compat.v "str"] where "str" is nonempty and
50+
either "n", "-n" (a signed integer), or "n.str".
51+
However, [Compat.v "0.14.rc1"] or so is not supported for now. *)
52+
let v = function
53+
| "" -> failwith "Compat.of_string"
54+
| s -> String.split_on_char '.' s |> List.map int_of_string
55+
56+
(** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0")
57+
are equal versions. But (v "1") is higher than (v "1.-1"), itself
58+
higher than (v "1.-2"), and so on. *)
59+
let rec le v1 v2 = match v1, v2 with
60+
| [], [] -> true
61+
| [], 0 :: l2 -> le [] l2
62+
| [], n2 :: _ -> 0 < n2
63+
| 0 :: l1, [] -> le l1 []
64+
| n1 :: _, [] -> n1 < 0
65+
| n1 :: l1, n2 :: l2 -> n1 < n2 || (n1 = n2 && le l1 l2)
66+
67+
let eq v1 v2 = le v1 v2 && le v2 v1
68+
69+
let lt v1 v2 = not (le v2 v1)
70+
71+
type pred =
72+
| Since of t (** >= v0 *)
73+
| Upto of t (** < v1 *)
74+
| And of pred * pred
75+
76+
let rec compat pred v =
77+
match pred with
78+
| Since v0 -> le v0 v
79+
| Upto v1 -> lt v v1
80+
| And (pred1, pred2) -> compat pred1 v && compat pred2 v
81+
82+
end
83+
84+
(* Tests
85+
assert Compat.(le (v "0.12") (v "0.13.0"));;
86+
assert Compat.(le (v "0.13.0") (v "0.13.1"));;
87+
assert Compat.(le (v "0.13.1") (v "0.14.0"));;
88+
assert Compat.(le (v "0.14.0") (v "1.0.0"));;
89+
assert Compat.(le (v "1.1.1") (v "1.1.1"));;
90+
assert Compat.(le (v "0.2") (v "0.10"));;
91+
assert Compat.(le (v "1.9.5") (v "1.10.0"));;
92+
*)
93+
1394
type _ request =
1495
| Static:
1596
string list -> string request
@@ -124,6 +205,48 @@ type _ request =
124205
| Invalid_request:
125206
string -> string request
126207

208+
let supported_versions
209+
: type resp. resp request -> Compat.pred
210+
= function
211+
| Static _
212+
| Version _
213+
| Nonce _
214+
| Create_token (_, _, _)
215+
| Create_teacher_token _
216+
| Fetch_save _
217+
| Archive_zip _
218+
| Update_save (_, _)
219+
| Git (_, _)
220+
| Students_list _
221+
| Set_students_list (_, _)
222+
| Students_csv (_, _, _)
223+
| Exercise_index _
224+
| Exercise (_, _)
225+
| Lesson_index _
226+
| Lesson _
227+
| Tutorial_index _
228+
| Tutorial _
229+
| Playground_index _
230+
| Playground _
231+
| Exercise_status_index _
232+
| Exercise_status (_, _)
233+
| Set_exercise_status (_, _)
234+
| Partition (_, _, _, _)
235+
| Invalid_request _ -> Compat.(Since (v "0.12"))
236+
237+
let is_supported
238+
: type resp. ?current:Compat.t -> server:Compat.t -> resp request ->
239+
(unit, string) result =
240+
fun ?(current = Compat.v Learnocaml_version.v) ~server request ->
241+
let supp = supported_versions request in
242+
if Compat.(compat (Since server) current) (* server <= current *)
243+
&& Compat.compat supp current (* request supported by current codebase *)
244+
&& Compat.compat supp server (* request supported by server *)
245+
then Ok () else
246+
Error (Printf.sprintf
247+
{|API request not supported by server v.%s using client v.%s|}
248+
(* NOTE: we may want to add some string_of_request call as well *)
249+
(Compat.to_string server) (Compat.to_string current))
127250

128251
type http_request = {
129252
meth: [ `GET | `POST of string];

src/state/learnocaml_api.mli

+62
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,60 @@ open Learnocaml_data
2323

2424
val version: string
2525

26+
module type COMPAT = sig
27+
(** List-based versions endowed with a lexicographic order. *)
28+
type t
29+
30+
val to_string : t -> string
31+
32+
(** Supported formats: [Compat.v "str"] where "str" is
33+
either "n", "-n" (a signed integer), or "n.str".
34+
However, [Compat.v "0.14.rc1"] or so is not supported for now. *)
35+
val v : string -> t
36+
37+
(** Note that trailing zeros are ignored, i.e. (v "1") and (v "1.0")
38+
are equal versions. But (v "1") is higher than (v "1.-1"), itself
39+
higher than (v "1.-2"), and so on. *)
40+
val le : t -> t -> bool
41+
42+
val eq : t -> t -> bool
43+
44+
val lt : t -> t -> bool
45+
46+
type pred =
47+
| Since of t | Upto of t | And of pred * pred
48+
49+
val compat : pred -> t -> bool
50+
end
51+
52+
module Compat: COMPAT
53+
54+
(** Note about backward-compatibility:
55+
56+
The architecture of learn-ocaml merges the (client, server) components
57+
in the same codebase, so it's easier to update both of them in one go.
58+
59+
But this tight coupling meant that a learn-ocaml-client version would
60+
only be compatible with a single server version, hence a frequent but
61+
annoying error "API version mismatch: client v._ and server v._".
62+
63+
So since learn-ocaml 0.13, a given client_version will try to be
64+
compatible with as much server_version's as possible (>= 0.12 &
65+
<= client_version).
66+
67+
To this aim, each [request] constructor is annotated with a version
68+
constraint of type [Compat.t], see [supported_versions].
69+
70+
Regarding the inevitable extensions of the API:
71+
72+
- make sure one only adds constructors to this [request] type,
73+
- and that their semantics does not change
74+
(or at least in a backward-compatible way;
75+
see PR https://github.com/ocaml-sf/learn-ocaml/pull/397
76+
for a counter-example)
77+
- but if a given entrypoint would need to be removed,
78+
rather add a Compat.Upto (*<*) constraint.
79+
*)
2680
type _ request =
2781
| Static:
2882
string list -> string request
@@ -140,6 +194,14 @@ type _ request =
140194
(** Only for server-side handling: bound to requests not matching any case
141195
above *)
142196

197+
val supported_versions: 'a request -> Compat.pred
198+
199+
(** [is supported client server req] = Ok () if
200+
[server <= client && current "supports" req && server "supports" client] *)
201+
val is_supported:
202+
?current:Compat.t -> server:Compat.t ->
203+
'resp request -> (unit, string) result
204+
143205
type http_request = {
144206
meth: [ `GET | `POST of string];
145207
host: string;

0 commit comments

Comments
 (0)