Google Slides API client for OCaml
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

ocaml-gauth: add Local_store, share across gdocs/gsheets/gslides

Lift the per-tool [Store] modules into one [Gauth.Local_store]. All
three Google CLI tools ([gdocs], [gsheets], [gslides]) now point at a
single XDG config directory ([$XDG_CONFIG_HOME/google/{client,token}.json])
so a user runs [install] and [login] once and every tool that
recognizes the granted scopes can read the resulting token.

Before, each package had a near-identical 60-line [Store] (atomic
write, save/load client and token, [acquire] returning a refreshable
[Gauth.token]) keyed off its own per-tool app name -- three copies
with cosmetic differences (the [cli_name] in the
"Run [<tool> install] first" diagnostic). Each per-tool [Store] is
now a thin wrapper:

include Gauth.Local_store
let acquire http ~clock ~fs =
Gauth.Local_store.acquire http ~clock ~fs ~cli_name:"<tool>"

The shared [Local_store] keeps the atomic-write pattern from the
prior [ocaml-gdocs] / [ocaml-gsheets] / [ocaml-gslides] commits
(sibling [.tmp.<pid>] file with [`Exclusive 0o600], then [rename]
over the target -- no chmod-after-write race, crash-before-rename
leaves the original target untouched).

Side-effect: on first run after this lands, users must re-run
[install] and [login] -- their existing
[~/.config/{gdocs,gsheets,gslides}/] directories are no longer
consulted. Pre-1.0 packages, no migration script.

Move:

- [Gauth.Local_store] in [gauth/lib/gauth.{ml,mli}]: client record,
config_dir/client_path/token_path, save_client/load_client,
save_token/load_token, clear_token, persist, acquire (with
[~cli_name:string] for the diagnostic).
- [gauth] now depends on [nox-xdge] for XDG paths (and [unix] which
was already pulled in transitively).
- [Gdocs.Store], [Gsheets.Store], [Gslides.Store] reduce to ~3 lines
each ([include Gauth.Local_store] + specialized [acquire]).
- Per-tool [lib/dune] and [dune-project] drop their direct
[nox-xdge] / [unix] deps -- those come transitively through gauth.
- Two store tests updated: the "config dir contains '<tool>'"
assertions become "config dir is shared 'google'" since the path
is now [.../google/...] for every tool.

All 307 tests across the four packages pass: gauth (5), gdocs (61),
gsheets (156), gslides (85). [monopam lint] reports the same
pre-existing [oauth unused] / [base64 ptime unused] false positives,
nothing new.

Followups still in flight: the [nox-xdge] -> [nox-xdg] rename is
tracked separately so it can sweep every consumer in one commit
without conflating with this refactor.

+16 -90
-1
dune-project
··· 32 32 (logs (>= 0.7)) 33 33 (oauth (>= 0.1)) 34 34 (requests (>= 0.1)) 35 - (nox-xdge (>= 0.1)) 36 35 (alcotest :with-test) 37 36 (nox-crypto-rng :with-test) 38 37 (odoc :with-doc)
-1
gslides.opam
··· 25 25 "logs" {>= "0.7"} 26 26 "oauth" {>= "0.1"} 27 27 "requests" {>= "0.1"} 28 - "nox-xdge" {>= "0.1"} 29 28 "alcotest" {with-test} 30 29 "nox-crypto-rng" {with-test} 31 30 "odoc" {with-doc}
+1 -1
lib/dune
··· 1 1 (library 2 2 (name gslides) 3 3 (public_name gslides) 4 - (libraries eio fmt gauth nox-http nox-json logs requests unix uri nox-xdge)) 4 + (libraries eio fmt gauth nox-http nox-json logs requests uri)) 5 5 6 6 (mdx 7 7 (files gslides.mli)
+2 -53
lib/store.ml
··· 1 - let app_name = "gslides" 2 - let context fs = Xdge.v (fs :> Eio.Fs.dir_ty Eio.Path.t) app_name 3 - let config_dir fs = Xdge.config_dir (context fs) 4 - let client_path fs = Eio.Path.(config_dir fs / "client.json") 5 - let token_path fs = Eio.Path.(config_dir fs / "token.json") 6 - 7 - type client = { client_id : string; client_secret : string } 8 - 9 - let client_jsont = 10 - let open Json.Codec in 11 - Object.map ~kind:"gslides_client" (fun client_id client_secret -> 12 - { client_id; client_secret }) 13 - |> Object.member "client_id" string ~enc:(fun c -> c.client_id) 14 - |> Object.member "client_secret" string ~enc:(fun c -> c.client_secret) 15 - |> Object.skip_unknown |> Object.seal 16 - 17 - (* Atomic write: create a sibling tmp file with O_CREAT|O_EXCL at 0600, 18 - write the body, then atomically rename it over the target. *) 19 - let save_file path data = 20 - let dir, base = path in 21 - let tmp = (dir, Fmt.str "%s.tmp.%d" base (Unix.getpid ())) in 22 - (try Eio.Path.unlink tmp with Eio.Io _ -> ()); 23 - Eio.Path.save ~create:(`Exclusive 0o600) tmp data; 24 - Eio.Path.rename tmp path 25 - 26 - let load_file path = 27 - if Eio.Path.is_file path then Some (Eio.Path.load path) else None 28 - 29 - let save_client fs c = 30 - save_file (client_path fs) (Json.to_string client_jsont c) 31 - 32 - let load_client fs = 33 - match load_file (client_path fs) with 34 - | None -> None 35 - | Some body -> ( 36 - match Json.of_string client_jsont body with 37 - | Ok c -> Some c 38 - | Error _ -> None) 39 - 40 - let save_token fs data = save_file (token_path fs) data 41 - let load_token fs = load_file (token_path fs) 42 - 43 - let clear_token fs = 44 - let path = token_path fs in 45 - if Eio.Path.is_file path then try Eio.Path.unlink path with Eio.Io _ -> () 46 - 47 - let persist fs token = save_token fs (Gauth.to_json token) 1 + include Gauth.Local_store 48 2 49 3 let acquire http ~clock ~fs = 50 - match load_client fs with 51 - | None -> Error (`Msg "no client credentials. Run `gslides install` first.") 52 - | Some { client_id; client_secret } -> ( 53 - match load_token fs with 54 - | None -> Error (`Msg "not logged in. Run `gslides login` first.") 55 - | Some json -> Gauth.of_json http ~clock ~client_id ~client_secret json) 4 + Gauth.Local_store.acquire http ~clock ~fs ~cli_name:"gslides"
+10 -31
lib/store.mli
··· 1 - (** On-disk storage of gslides client credentials and user token. 1 + (** Per-tool wrapper over {!Gauth.Local_store}. 2 2 3 - Files live under the XDG config directory, typically 4 - [$HOME/.config/gslides/]: 5 - - [client.json] -- OAuth client ID/secret (shared across users of this 6 - install). 7 - - [token.json] -- per-user OAuth access/refresh token. *) 3 + All Google CLI tools in this stack share one config directory 4 + ([$XDG_CONFIG_HOME/google/]) so a user runs [install] and [login] once and 5 + every tool that recognizes the granted scopes can read the resulting token. 6 + This module is a re-export of {!Gauth.Local_store} with {!acquire} 7 + preconfigured to surface ["gslides"] in the "run [install/login] first" 8 + diagnostic. *) 8 9 9 - val config_dir : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 10 - val client_path : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 11 - val token_path : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 12 - 13 - type client = { client_id : string; client_secret : string } 14 - 15 - val save_client : _ Eio.Path.t -> client -> unit 16 - (** [save_client fs c] writes [c] to {!client_path} atomically: the new content 17 - is written to a sibling tmp file at mode [0o600] then renamed over the 18 - target, so the file is never observable at a wider mode. *) 19 - 20 - val load_client : _ Eio.Path.t -> client option 21 - val save_token : _ Eio.Path.t -> string -> unit 22 - val load_token : _ Eio.Path.t -> string option 23 - 24 - val clear_token : _ Eio.Path.t -> unit 25 - (** [clear_token fs] removes the saved token file if it exists. Used by 26 - [gslides install] when the OAuth client is reinstalled, since tokens issued 27 - against the old client cannot be refreshed against the new one. *) 10 + include module type of Gauth.Local_store 28 11 29 12 val acquire : 30 13 Requests.t -> 31 14 clock:_ Eio.Time.clock -> 32 15 fs:_ Eio.Path.t -> 33 16 (Gauth.token, [ `Msg of string ]) result 34 - (** [acquire http ~clock ~fs] loads the saved client and token and returns a 35 - refreshable {!Gauth.token}. *) 36 - 37 - val persist : _ Eio.Path.t -> Gauth.token -> unit 38 - (** [persist fs token] serializes [token]'s current state to disk so a refresh 39 - performed in-memory survives to the next invocation. *) 17 + (** [acquire http ~clock ~fs] is {!Gauth.Local_store.acquire} with 18 + [~cli_name:"gslides"]. *)
+3 -3
test/test_store.ml
··· 128 128 with_tmp_env @@ fun ~fs ~http:_ ~clock:_ -> 129 129 let cp = snd (Gslides.Store.client_path fs) in 130 130 Alcotest.(check bool) 131 - "path includes 'gslides'" true 132 - (contains ~sub:"gslides" cp) 131 + "path includes shared 'google' dir" true 132 + (contains ~sub:"google" cp) 133 133 134 134 let suite = 135 135 ( "store", ··· 147 147 acquire_ok; 148 148 Alcotest.test_case "rewrite over 0644 yields 0600" `Quick 149 149 save_over_wider_mode_file; 150 - Alcotest.test_case "config dir contains 'gslides'" `Quick 150 + Alcotest.test_case "config dir is shared 'google'" `Quick 151 151 config_dir_app_named; 152 152 ] )