Skip to content

Commit

Permalink
feat: Relax the client/server coupling & Add support for API versioni…
Browse files Browse the repository at this point in the history
…ng (#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
  • Loading branch information
erikmd committed Sep 29, 2021
1 parent a825d7e commit 3113861
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 12 deletions.
2 changes: 1 addition & 1 deletion dune-project
@@ -1,4 +1,4 @@
(lang dune 2.3)
(name learn-ocaml)
(version 0.12)
(version 0.13.0)
(allow_approximate_merlin)
2 changes: 1 addition & 1 deletion 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)"
Expand Down
2 changes: 1 addition & 1 deletion 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
Expand Down
2 changes: 1 addition & 1 deletion 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)"
Expand Down
2 changes: 1 addition & 1 deletion 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)"
Expand Down
36 changes: 29 additions & 7 deletions src/main/learnocaml_client.ml
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions src/state/learnocaml_api.ml
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
62 changes: 62 additions & 0 deletions src/state/learnocaml_api.mli
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 3113861

Please sign in to comment.