Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Relax the client/server coupling & Add support for API versioning #426

Merged
merged 2 commits into from Sep 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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