Immich bindings and CLI in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** Immich API error handling using NestJS error format. *)
7
8(** Re-export NestJS error type for convenience. *)
9type t = Openapi.Nestjs.t = {
10 status_code : int;
11 error : string option;
12 message : string;
13 correlation_id : string option;
14}
15
16(** Parse an API error into a structured Immich/NestJS error. *)
17let of_api_error = Openapi.Nestjs.of_api_error
18
19(** {1 Styled Output Helpers} *)
20
21(** Style for error labels (red, bold) *)
22let error_style = Fmt.(styled (`Fg `Red) (styled `Bold string))
23
24(** Style for status codes *)
25let status_style status =
26 if status >= 500 then Fmt.(styled (`Fg `Red) int)
27 else if status >= 400 then Fmt.(styled (`Fg `Yellow) int)
28 else Fmt.(styled (`Fg `Green) int)
29
30(** Style for correlation IDs (dim) *)
31let correlation_style = Fmt.(styled `Faint string)
32
33(** Style for error type (bold) *)
34let error_type_style = Fmt.(styled `Bold string)
35
36(** Pretty-print an Immich API error with colors.
37
38 Format: "Forbidden: Missing required permission [403] (correlationId: abc123)" *)
39let pp ppf (e : t) =
40 match e.error with
41 | Some err ->
42 Fmt.pf ppf "%a: %s [%a]"
43 error_type_style err
44 e.message
45 (status_style e.status_code) e.status_code;
46 (match e.correlation_id with
47 | Some cid -> Fmt.pf ppf " (%a)" correlation_style (Printf.sprintf "correlationId: %s" cid)
48 | None -> ())
49 | None ->
50 Fmt.pf ppf "%s [%a]"
51 e.message
52 (status_style e.status_code) e.status_code;
53 (match e.correlation_id with
54 | Some cid -> Fmt.pf ppf " (%a)" correlation_style (Printf.sprintf "correlationId: %s" cid)
55 | None -> ())
56
57(** Convert to a human-readable string (without colors). *)
58let to_string (e : t) : string =
59 let error_prefix = match e.error with
60 | Some err -> err ^ ": "
61 | None -> ""
62 in
63 match e.correlation_id with
64 | Some cid ->
65 Printf.sprintf "%s%s [%d] (correlationId: %s)"
66 error_prefix e.message e.status_code cid
67 | None ->
68 Printf.sprintf "%s%s [%d]" error_prefix e.message e.status_code
69
70(** Check if this is an authentication/authorization error. *)
71let is_auth_error = Openapi.Nestjs.is_auth_error
72
73(** Check if this is a "not found" error. *)
74let is_not_found = Openapi.Nestjs.is_not_found
75
76(** Handle an exception, printing a nice error message if it's an API error.
77
78 Returns an exit code:
79 - 0 if not an error (shouldn't happen, but for completeness)
80 - 1 for most API errors
81 - 77 for authentication errors (permission denied)
82 - 69 for not found errors *)
83let handle_exn exn =
84 match exn with
85 | Openapi.Runtime.Api_error e ->
86 (match of_api_error e with
87 | Some nestjs ->
88 Fmt.epr "%a %a@." error_style "Error:" pp nestjs;
89 if is_auth_error nestjs then 77
90 else if is_not_found nestjs then 69
91 else 1
92 | None ->
93 (* Not a NestJS error, show raw response *)
94 Fmt.epr "%a %s %s returned %a@.%s@."
95 error_style "API Error:"
96 e.method_ e.url
97 (status_style e.status) e.status
98 e.body;
99 1)
100 | Failure msg ->
101 Fmt.epr "%a %s@." error_style "Error:" msg;
102 1
103 | exn ->
104 (* Re-raise unknown exceptions *)
105 raise exn
106
107(** Wrap a function to handle API errors gracefully.
108
109 Usage:
110 {[
111 let () = Immich_auth.Error.run (fun () ->
112 let albums = Immich.Albums.get_all_albums client () in
113 ...
114 )
115 ]} *)
116let run f =
117 try f (); 0
118 with exn -> handle_exn exn
119
120(** Exception to signal desired exit code without calling [exit] directly.
121 This avoids issues when running inside Eio's event loop. *)
122exception Exit_code of int
123
124(** Wrap a command action to handle API errors gracefully.
125
126 This is designed to be used in cmdliner command definitions:
127 {[
128 let list_action ~profile env =
129 Immich_auth.Error.wrap (fun () ->
130 let api = ... in
131 let albums = Immich.Albums.get_all_albums api () in
132 ...
133 )
134
135 let list_cmd env fs =
136 Cmd.v info Term.(const list_action $ ...)
137 ]}
138
139 The wrapper catches API errors and prints a nice message,
140 then raises [Exit_code] with an appropriate code. This exception
141 should be caught by the main program outside the Eio event loop. *)
142let wrap f =
143 try f ()
144 with
145 | Stdlib.Exit ->
146 (* exit() was called somewhere - treat as success *)
147 ()
148 | Eio.Cancel.Cancelled Stdlib.Exit ->
149 (* Eio wraps Exit in Cancelled - treat as success *)
150 ()
151 | exn ->
152 let code = handle_exn exn in
153 raise (Exit_code code)