Skip to main content

koan_core/
graphql_client.rs

1//! Lightweight GraphQL client for connecting to a `koan serve` instance.
2//!
3//! Uses blocking reqwest — call from a background thread when used from the TUI.
4
5use std::sync::Arc;
6
7use parking_lot::Mutex;
8use reqwest::StatusCode;
9use serde_json::Value;
10
11use crate::config::Config;
12
13/// A GraphQL client that talks to a koan server.
14///
15/// Clones share one session, so a token refreshed by one thread is the token
16/// every other thread uses next.
17#[derive(Clone)]
18pub struct GraphQLClient {
19    url: String,
20    http: reqwest::blocking::Client,
21    session: Option<Arc<Session>>,
22}
23
24/// A sign-in to a server with auth enabled: the refresh token `koan auth login`
25/// stored, and the access token it last bought.
26struct Session {
27    tokens: Mutex<Tokens>,
28    /// Called with each new refresh token. The server spends the old one on
29    /// every refresh, so a copy not written back is dead.
30    on_rotate: Box<dyn Fn(&str) + Send + Sync>,
31}
32
33struct Tokens {
34    access: Option<String>,
35    refresh: String,
36    /// Why the server refused the refresh token. Set once and kept: the token
37    /// will not become valid again, and the bridge polls several times a second.
38    refused: Option<String>,
39}
40
41impl GraphQLClient {
42    pub fn new(server_url: &str) -> Self {
43        let url = format!("{}/graphql", server_url.trim_end_matches('/'));
44        Self {
45            url,
46            http: reqwest::blocking::Client::builder()
47                .timeout(std::time::Duration::from_secs(30))
48                .build()
49                .expect("failed to build HTTP client"),
50            session: None,
51        }
52    }
53
54    /// A client carrying the sign-in stored in `[auth]`, when it is for this
55    /// server. Rotated refresh tokens are written back through `Config::persist`.
56    pub fn from_config(server_url: &str) -> Self {
57        let client = Self::new(server_url);
58        let cfg = Config::load().unwrap_or_default();
59        if cfg.auth.refresh_token.is_empty()
60            || cfg.auth.server.trim_end_matches('/') != client.server_url()
61        {
62            return client;
63        }
64        client.with_session(cfg.auth.refresh_token, |token| {
65            if let Err(e) = Config::persist(|cfg| cfg.auth.refresh_token = token.to_owned()) {
66                log::warn!("could not store the rotated refresh token: {e}");
67            }
68        })
69    }
70
71    /// Authenticate with `refresh_token`, exchanging it for an access token
72    /// when the server first refuses a request.
73    pub fn with_session(
74        mut self,
75        refresh_token: impl Into<String>,
76        on_rotate: impl Fn(&str) + Send + Sync + 'static,
77    ) -> Self {
78        self.session = Some(Arc::new(Session {
79            tokens: Mutex::new(Tokens {
80                access: None,
81                refresh: refresh_token.into(),
82                refused: None,
83            }),
84            on_rotate: Box::new(on_rotate),
85        }));
86        self
87    }
88
89    /// Whether requests carry a stored sign-in.
90    pub fn has_session(&self) -> bool {
91        self.session.is_some()
92    }
93
94    /// Execute a raw GraphQL query/mutation.
95    ///
96    /// Access tokens are short-lived, so a 401 is answered by one refresh and
97    /// one retry; a second refusal is `GraphQLError::Unauthorized`.
98    pub fn execute(&self, query: &str, variables: Option<Value>) -> Result<Value, GraphQLError> {
99        let mut body = serde_json::json!({ "query": query });
100        if let Some(vars) = variables {
101            body["variables"] = vars;
102        }
103
104        let access = self
105            .session
106            .as_ref()
107            .and_then(|s| s.tokens.lock().access.clone());
108        let mut resp = self.post(&body, access.as_deref())?;
109        if resp.status() == StatusCode::UNAUTHORIZED
110            && let Some(session) = &self.session
111        {
112            let fresh = self.refresh(session, access.as_deref())?;
113            resp = self.post(&body, Some(&fresh))?;
114        }
115
116        let status = resp.status();
117        if status == StatusCode::UNAUTHORIZED {
118            return Err(GraphQLError::Unauthorized(reason(resp)));
119        }
120        if !status.is_success() {
121            return Err(GraphQLError::Http(format!("{status}: {}", reason(resp))));
122        }
123
124        let resp: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
125
126        if let Some(errors) = resp.get("errors")
127            && let Some(arr) = errors.as_array()
128            && !arr.is_empty()
129        {
130            let msg = arr[0]
131                .get("message")
132                .and_then(|m| m.as_str())
133                .unwrap_or("unknown error");
134            return Err(GraphQLError::Query(msg.to_string()));
135        }
136
137        Ok(resp.get("data").cloned().unwrap_or(Value::Null))
138    }
139
140    fn post(
141        &self,
142        body: &Value,
143        access: Option<&str>,
144    ) -> Result<reqwest::blocking::Response, GraphQLError> {
145        let mut req = self.http.post(&self.url).json(body);
146        if let Some(token) = access {
147            req = req.bearer_auth(token);
148        }
149        req.send().map_err(|e| GraphQLError::Http(e.to_string()))
150    }
151
152    /// A new access token, replacing `stale`.
153    ///
154    /// The lock is held across the request: the server spends a refresh token
155    /// on use, so two threads refreshing at once would leave one holding a
156    /// revoked token. A thread that waited finds `stale` already replaced and
157    /// takes the new token instead.
158    fn refresh(&self, session: &Session, stale: Option<&str>) -> Result<String, GraphQLError> {
159        let mut tokens = session.tokens.lock();
160        if let Some(access) = &tokens.access
161            && Some(access.as_str()) != stale
162        {
163            return Ok(access.clone());
164        }
165        if let Some(reason) = &tokens.refused {
166            return Err(GraphQLError::Unauthorized(reason.clone()));
167        }
168
169        let resp = self
170            .http
171            .post(format!("{}/auth/refresh", self.server_url()))
172            .json(&serde_json::json!({ "refresh_token": tokens.refresh }))
173            .send()
174            .map_err(|e| GraphQLError::Http(e.to_string()))?;
175        if resp.status() == StatusCode::UNAUTHORIZED {
176            let refused = format!("the stored sign-in was refused: {}", reason(resp));
177            tokens.access = None;
178            tokens.refused = Some(refused.clone());
179            return Err(GraphQLError::Unauthorized(refused));
180        }
181        if !resp.status().is_success() {
182            let status = resp.status();
183            return Err(GraphQLError::Http(format!(
184                "/auth/refresh {status}: {}",
185                reason(resp)
186            )));
187        }
188
189        let body: Value = resp.json().map_err(|e| GraphQLError::Http(e.to_string()))?;
190        let (Some(access), Some(refresh)) = (
191            body["access_token"].as_str(),
192            body["refresh_token"].as_str(),
193        ) else {
194            return Err(GraphQLError::Http(
195                "malformed /auth/refresh response".into(),
196            ));
197        };
198        tokens.access = Some(access.to_owned());
199        tokens.refresh = refresh.to_owned();
200        (session.on_rotate)(refresh);
201        Ok(access.to_owned())
202    }
203
204    // -----------------------------------------------------------------------
205    // Typed helpers
206    // -----------------------------------------------------------------------
207
208    pub fn now_playing(&self) -> Result<NowPlaying, GraphQLError> {
209        let data = self.execute(
210            "{ nowPlaying { state positionMs durationMs queueItemId \
211             track { trackId title artist album codec sampleRate bitDepth bitrateKbps channels durationMs } } }",
212            None,
213        )?;
214        let np = &data["nowPlaying"];
215        Ok(NowPlaying {
216            state: np["state"].as_str().unwrap_or("STOPPED").to_string(),
217            position_ms: np["positionMs"].as_u64().unwrap_or(0),
218            duration_ms: np["durationMs"].as_u64(),
219            queue_item_id: np["queueItemId"].as_str().map(String::from),
220            track: np.get("track").and_then(|t| {
221                if t.is_null() {
222                    return None;
223                }
224                Some(NowPlayingTrack {
225                    track_id: t["trackId"].as_str().map(String::from),
226                    title: t["title"].as_str().unwrap_or("").to_string(),
227                    artist: t["artist"].as_str().unwrap_or("").to_string(),
228                    album: t["album"].as_str().unwrap_or("").to_string(),
229                    codec: t["codec"].as_str().unwrap_or("").to_string(),
230                    sample_rate: t["sampleRate"].as_u64().unwrap_or(0) as u32,
231                    bit_depth: t["bitDepth"].as_u64().map(|v| v as u16),
232                    bitrate_kbps: t["bitrateKbps"].as_u64().map(|v| v as u32),
233                    channels: t["channels"].as_u64().unwrap_or(0) as u16,
234                    duration_ms: t["durationMs"].as_u64().unwrap_or(0),
235                })
236            }),
237        })
238    }
239
240    pub fn queue(&self) -> Result<Vec<QueueEntry>, GraphQLError> {
241        let data = self.execute(
242            "{ queue { queueItemId trackId title artist album codec trackNumber disc durationMs isCurrent } }",
243            None,
244        )?;
245        let entries = data["queue"]
246            .as_array()
247            .map(|arr| {
248                arr.iter()
249                    .map(|e| QueueEntry {
250                        queue_item_id: e["queueItemId"].as_str().unwrap_or("").to_string(),
251                        track_id: e["trackId"].as_str().map(String::from),
252                        title: e["title"].as_str().unwrap_or("").to_string(),
253                        artist: e["artist"].as_str().unwrap_or("").to_string(),
254                        album: e["album"].as_str().unwrap_or("").to_string(),
255                        codec: e["codec"].as_str().map(String::from),
256                        track_number: e["trackNumber"].as_i64(),
257                        disc: e["disc"].as_i64(),
258                        duration_ms: e["durationMs"].as_u64(),
259                        is_current: e["isCurrent"].as_bool().unwrap_or(false),
260                    })
261                    .collect()
262            })
263            .unwrap_or_default();
264        Ok(entries)
265    }
266
267    pub fn search(&self, query: &str, limit: u32) -> Result<Vec<TrackResult>, GraphQLError> {
268        let data = self.execute(
269            "query($search: String!, $first: Int) { tracks(search: $search, first: $first) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
270            Some(serde_json::json!({ "search": query, "first": limit })),
271        )?;
272        parse_track_edges(&data["tracks"])
273    }
274
275    pub fn artists(&self) -> Result<Vec<ArtistResult>, GraphQLError> {
276        let data = self.execute("{ artists { edges { node { id name } } } }", None)?;
277        let edges = data["artists"]["edges"].as_array();
278        Ok(edges
279            .map(|arr| {
280                arr.iter()
281                    .map(|e| {
282                        let n = &e["node"];
283                        ArtistResult {
284                            id: n["id"].as_str().unwrap_or("").to_string(),
285                            name: n["name"].as_str().unwrap_or("").to_string(),
286                        }
287                    })
288                    .collect()
289            })
290            .unwrap_or_default())
291    }
292
293    pub fn albums_for_artist(&self, artist_id: &str) -> Result<Vec<AlbumResult>, GraphQLError> {
294        let data = self.execute(
295            "query($artistId: ID!) { albums(artistId: $artistId) { edges { node { id title artistName date codec } } } }",
296            Some(serde_json::json!({ "artistId": artist_id })),
297        )?;
298        parse_album_edges(&data["albums"])
299    }
300
301    pub fn tracks_for_album(&self, album_id: &str) -> Result<Vec<TrackResult>, GraphQLError> {
302        let data = self.execute(
303            "query($albumId: ID!) { tracks(albumId: $albumId) { edges { node { id title artist album albumId artistId disc trackNumber durationMs codec genre source } } } }",
304            Some(serde_json::json!({ "albumId": album_id })),
305        )?;
306        parse_track_edges(&data["tracks"])
307    }
308
309    pub fn fuzzy_search(
310        &self,
311        query: &str,
312        kind: &str,
313        limit: u32,
314    ) -> Result<Vec<FuzzyMatch>, GraphQLError> {
315        let data = self.execute(
316            "query($query: String!, $kind: FuzzySearchKind!, $limit: Int) { fuzzySearch(query: $query, kind: $kind, limit: $limit) { id name rank kind } }",
317            Some(serde_json::json!({ "query": query, "kind": kind, "limit": limit })),
318        )?;
319        Ok(data["fuzzySearch"]
320            .as_array()
321            .map(|arr| {
322                arr.iter()
323                    .map(|e| FuzzyMatch {
324                        id: e["id"].as_str().unwrap_or("").to_string(),
325                        name: e["name"].as_str().unwrap_or("").to_string(),
326                        rank: e["rank"].as_i64().unwrap_or(0) as i32,
327                    })
328                    .collect()
329            })
330            .unwrap_or_default())
331    }
332
333    // -- Mutations --
334
335    pub fn pause(&self) -> Result<(), GraphQLError> {
336        self.execute("mutation { pause { ok } }", None)?;
337        Ok(())
338    }
339
340    pub fn resume(&self) -> Result<(), GraphQLError> {
341        self.execute("mutation { resume { ok } }", None)?;
342        Ok(())
343    }
344
345    pub fn stop(&self) -> Result<(), GraphQLError> {
346        self.execute("mutation { stop { ok } }", None)?;
347        Ok(())
348    }
349
350    pub fn next(&self) -> Result<(), GraphQLError> {
351        self.execute("mutation { next { ok } }", None)?;
352        Ok(())
353    }
354
355    pub fn previous(&self) -> Result<(), GraphQLError> {
356        self.execute("mutation { previous { ok } }", None)?;
357        Ok(())
358    }
359
360    pub fn seek(&self, position_ms: u64) -> Result<(), GraphQLError> {
361        self.execute(
362            "mutation($positionMs: Int!) { seek(positionMs: $positionMs) { ok } }",
363            Some(serde_json::json!({ "positionMs": position_ms })),
364        )?;
365        Ok(())
366    }
367
368    pub fn play(&self, queue_item_id: &str) -> Result<(), GraphQLError> {
369        self.execute(
370            "mutation($queueItemId: String!) { play(queueItemId: $queueItemId) { ok } }",
371            Some(serde_json::json!({ "queueItemId": queue_item_id })),
372        )?;
373        Ok(())
374    }
375
376    pub fn add_to_queue(&self, track_ids: &[String]) -> Result<Vec<String>, GraphQLError> {
377        let data = self.execute(
378            "mutation($trackIds: [ID!]!) { addToQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
379            Some(serde_json::json!({ "trackIds": track_ids })),
380        )?;
381        Ok(data["addToQueue"]["queueItemIds"]
382            .as_array()
383            .map(|arr| {
384                arr.iter()
385                    .filter_map(|v| v.as_str().map(String::from))
386                    .collect()
387            })
388            .unwrap_or_default())
389    }
390
391    pub fn replace_queue(&self, track_ids: &[String]) -> Result<Vec<String>, GraphQLError> {
392        let data = self.execute(
393            "mutation($trackIds: [ID!]!) { replaceQueue(trackIds: $trackIds) { ok addedCount queueItemIds } }",
394            Some(serde_json::json!({ "trackIds": track_ids })),
395        )?;
396        Ok(data["replaceQueue"]["queueItemIds"]
397            .as_array()
398            .map(|arr| {
399                arr.iter()
400                    .filter_map(|v| v.as_str().map(String::from))
401                    .collect()
402            })
403            .unwrap_or_default())
404    }
405
406    pub fn clear_queue(&self) -> Result<(), GraphQLError> {
407        self.execute("mutation { clearQueue { ok } }", None)?;
408        Ok(())
409    }
410
411    pub fn favourite(&self, track_id: &str) -> Result<(), GraphQLError> {
412        self.execute(
413            "mutation($trackId: ID!) { favourite(trackId: $trackId) { id } }",
414            Some(serde_json::json!({ "trackId": track_id })),
415        )?;
416        Ok(())
417    }
418
419    pub fn unfavourite(&self, track_id: &str) -> Result<(), GraphQLError> {
420        self.execute(
421            "mutation($trackId: ID!) { unfavourite(trackId: $trackId) { id } }",
422            Some(serde_json::json!({ "trackId": track_id })),
423        )?;
424        Ok(())
425    }
426
427    pub fn save_queue_as_playlist(&self, name: &str) -> Result<(), GraphQLError> {
428        self.execute(
429            "mutation($name: String!) { saveQueueAsPlaylist(name: $name) { id } }",
430            Some(serde_json::json!({ "name": name })),
431        )?;
432        Ok(())
433    }
434
435    pub fn play_playlist(&self, id: &str, shuffled: bool) -> Result<(), GraphQLError> {
436        self.execute(
437            "mutation($id: ID!, $shuffled: Boolean!)              { playPlaylist(id: $id, shuffled: $shuffled) { ok } }",
438            Some(serde_json::json!({ "id": id, "shuffled": shuffled })),
439        )?;
440        Ok(())
441    }
442
443    pub fn enable_radio(&self) -> Result<(), GraphQLError> {
444        self.execute("mutation { enableRadio { ok } }", None)?;
445        Ok(())
446    }
447
448    pub fn disable_radio(&self) -> Result<(), GraphQLError> {
449        self.execute("mutation { disableRadio { ok } }", None)?;
450        Ok(())
451    }
452
453    pub fn library_stats(&self) -> Result<Value, GraphQLError> {
454        self.execute(
455            "{ libraryStats { totalTracks totalArtists totalAlbums localTracks remoteTracks cachedTracks } }",
456            None,
457        )
458    }
459
460    /// Server URL (without /graphql path).
461    pub fn server_url(&self) -> &str {
462        self.url.trim_end_matches("/graphql")
463    }
464}
465
466// ---------------------------------------------------------------------------
467// Result types
468// ---------------------------------------------------------------------------
469
470#[derive(Debug, thiserror::Error)]
471pub enum GraphQLError {
472    #[error("http error: {0}")]
473    Http(String),
474    #[error("query error: {0}")]
475    Query(String),
476    #[error("unauthorised: {0}")]
477    Unauthorized(String),
478}
479
480/// What the server said when it refused a request: the `message` of a JSON
481/// body, or the body as text.
482fn reason(resp: reqwest::blocking::Response) -> String {
483    let status = resp.status();
484    let text = resp.text().unwrap_or_default();
485    let message = serde_json::from_str::<Value>(&text)
486        .ok()
487        .and_then(|v| v["message"].as_str().map(str::to_owned))
488        .unwrap_or(text);
489    if message.trim().is_empty() {
490        status.to_string()
491    } else {
492        message.trim().to_owned()
493    }
494}
495
496#[derive(Debug, Clone)]
497pub struct NowPlaying {
498    pub state: String,
499    pub position_ms: u64,
500    pub duration_ms: Option<u64>,
501    pub queue_item_id: Option<String>,
502    pub track: Option<NowPlayingTrack>,
503}
504
505#[derive(Debug, Clone)]
506pub struct NowPlayingTrack {
507    /// The track's id on the server. `None` for a queue entry the server built
508    /// from a file with no database row, which cannot be streamed.
509    pub track_id: Option<String>,
510    pub title: String,
511    pub artist: String,
512    pub album: String,
513    pub codec: String,
514    pub sample_rate: u32,
515    pub bit_depth: Option<u16>,
516    pub bitrate_kbps: Option<u32>,
517    pub channels: u16,
518    pub duration_ms: u64,
519}
520
521#[derive(Debug, Clone)]
522pub struct QueueEntry {
523    pub queue_item_id: String,
524    pub track_id: Option<String>,
525    pub title: String,
526    pub artist: String,
527    pub album: String,
528    pub codec: Option<String>,
529    pub track_number: Option<i64>,
530    pub disc: Option<i64>,
531    pub duration_ms: Option<u64>,
532    pub is_current: bool,
533}
534
535#[derive(Debug, Clone)]
536pub struct TrackResult {
537    pub id: String,
538    pub title: String,
539    pub artist: String,
540    pub album: String,
541    pub album_id: Option<String>,
542    pub artist_id: Option<String>,
543    pub disc: Option<i32>,
544    pub track_number: Option<i32>,
545    pub duration_ms: Option<i64>,
546    pub codec: Option<String>,
547    pub genre: Option<String>,
548    pub source: String,
549}
550
551#[derive(Debug, Clone)]
552pub struct ArtistResult {
553    pub id: String,
554    pub name: String,
555}
556
557#[derive(Debug, Clone)]
558pub struct AlbumResult {
559    pub id: String,
560    pub title: String,
561    pub artist_name: String,
562    pub date: Option<String>,
563    pub codec: Option<String>,
564}
565
566#[derive(Debug, Clone)]
567pub struct FuzzyMatch {
568    pub id: String,
569    pub name: String,
570    pub rank: i32,
571}
572
573// ---------------------------------------------------------------------------
574// Parse helpers
575// ---------------------------------------------------------------------------
576
577fn parse_track_edges(connection: &Value) -> Result<Vec<TrackResult>, GraphQLError> {
578    Ok(connection["edges"]
579        .as_array()
580        .map(|arr| {
581            arr.iter()
582                .map(|e| {
583                    let n = &e["node"];
584                    TrackResult {
585                        id: n["id"].as_str().unwrap_or("").to_string(),
586                        title: n["title"].as_str().unwrap_or("").to_string(),
587                        artist: n["artist"].as_str().unwrap_or("").to_string(),
588                        album: n["album"].as_str().unwrap_or("").to_string(),
589                        album_id: n["albumId"].as_str().map(String::from),
590                        artist_id: n["artistId"].as_str().map(String::from),
591                        disc: n["disc"].as_i64().map(|v| v as i32),
592                        track_number: n["trackNumber"].as_i64().map(|v| v as i32),
593                        duration_ms: n["durationMs"].as_i64(),
594                        codec: n["codec"].as_str().map(String::from),
595                        genre: n["genre"].as_str().map(String::from),
596                        source: n["source"].as_str().unwrap_or("local").to_string(),
597                    }
598                })
599                .collect()
600        })
601        .unwrap_or_default())
602}
603
604fn parse_album_edges(connection: &Value) -> Result<Vec<AlbumResult>, GraphQLError> {
605    Ok(connection["edges"]
606        .as_array()
607        .map(|arr| {
608            arr.iter()
609                .map(|e| {
610                    let n = &e["node"];
611                    AlbumResult {
612                        id: n["id"].as_str().unwrap_or("").to_string(),
613                        title: n["title"].as_str().unwrap_or("").to_string(),
614                        artist_name: n["artistName"].as_str().unwrap_or("").to_string(),
615                        date: n["date"].as_str().map(String::from),
616                        codec: n["codec"].as_str().map(String::from),
617                    }
618                })
619                .collect()
620        })
621        .unwrap_or_default())
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn client_constructs_url() {
630        let c = GraphQLClient::new("http://localhost:4000");
631        assert_eq!(c.url, "http://localhost:4000/graphql");
632    }
633
634    #[test]
635    fn client_trailing_slash() {
636        let c = GraphQLClient::new("http://localhost:4000/");
637        assert_eq!(c.url, "http://localhost:4000/graphql");
638    }
639
640    /// A request as the test server saw it.
641    struct Seen {
642        path: String,
643        bearer: Option<String>,
644        body: String,
645    }
646
647    /// An HTTP server on a loopback port answering each request with
648    /// `respond`. Returns its base URL and every request it received.
649    fn serve(
650        respond: impl Fn(&Seen) -> (u16, &'static str) + Send + 'static,
651    ) -> (String, Arc<Mutex<Vec<String>>>) {
652        use std::io::{BufRead, BufReader, Read, Write};
653
654        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
655        let base = format!("http://{}", listener.local_addr().unwrap());
656        let log = Arc::new(Mutex::new(Vec::new()));
657        let seen_log = log.clone();
658        std::thread::spawn(move || {
659            for stream in listener.incoming() {
660                let Ok(mut stream) = stream else { return };
661                let mut reader = BufReader::new(stream.try_clone().unwrap());
662                let mut line = String::new();
663                reader.read_line(&mut line).unwrap();
664                let path = line.split_whitespace().nth(1).unwrap_or("").to_owned();
665                let (mut bearer, mut length) = (None, 0);
666                loop {
667                    line.clear();
668                    reader.read_line(&mut line).unwrap();
669                    let header = line.trim_end();
670                    if header.is_empty() {
671                        break;
672                    }
673                    let (name, value) = header.split_once(": ").unwrap_or((header, ""));
674                    match name.to_ascii_lowercase().as_str() {
675                        "authorization" => {
676                            bearer = value.strip_prefix("Bearer ").map(str::to_owned)
677                        }
678                        "content-length" => length = value.parse().unwrap_or(0),
679                        _ => {}
680                    }
681                }
682                let mut body = vec![0; length];
683                reader.read_exact(&mut body).unwrap();
684                let seen = Seen {
685                    path,
686                    bearer,
687                    body: String::from_utf8_lossy(&body).into_owned(),
688                };
689                seen_log.lock().push(seen.path.clone());
690                let (code, reply) = respond(&seen);
691                write!(
692                    stream,
693                    "HTTP/1.1 {code} X\r\nContent-Type: application/json\r\n\
694                     Content-Length: {}\r\nConnection: close\r\n\r\n{reply}",
695                    reply.len()
696                )
697                .unwrap();
698            }
699        });
700        (base, log)
701    }
702
703    /// A server that takes `access-2` and trades `refresh-1` for it once.
704    fn signed_in_server(seen: &Seen) -> (u16, &'static str) {
705        match seen.path.as_str() {
706            "/graphql" if seen.bearer.as_deref() == Some("access-2") => {
707                (200, r#"{"data":{"ok":true}}"#)
708            }
709            "/auth/refresh" if seen.body.contains("refresh-1") => (
710                200,
711                r#"{"access_token":"access-2","refresh_token":"refresh-2"}"#,
712            ),
713            "/auth/refresh" => (401, r#"{"message":"invalid or expired refresh token"}"#),
714            _ => (401, "missing or invalid Authorization header"),
715        }
716    }
717
718    #[test]
719    fn refused_request_refreshes_once_and_retries() {
720        let (base, log) = serve(signed_in_server);
721        let rotated = Arc::new(Mutex::new(Vec::new()));
722        let stored = rotated.clone();
723        let client = GraphQLClient::new(&base)
724            .with_session("refresh-1", move |t| stored.lock().push(t.to_owned()));
725
726        let data = client.execute("{ ok }", None).unwrap();
727        assert_eq!(data["ok"], true);
728        assert_eq!(*rotated.lock(), ["refresh-2"]);
729
730        // The new access token is kept: no second refresh.
731        client.clone().execute("{ ok }", None).unwrap();
732        assert_eq!(
733            *log.lock(),
734            ["/graphql", "/auth/refresh", "/graphql", "/graphql"]
735        );
736    }
737
738    #[test]
739    fn refused_refresh_is_unauthorised() {
740        let (base, log) = serve(signed_in_server);
741        let client = GraphQLClient::new(&base).with_session("revoked", |_| {});
742
743        for _ in 0..2 {
744            match client.execute("{ ok }", None) {
745                Err(GraphQLError::Unauthorized(msg)) => {
746                    assert!(msg.contains("invalid or expired refresh token"), "{msg}")
747                }
748                other => panic!("expected Unauthorized, got {other:?}"),
749            }
750        }
751        // A refused refresh token is not offered again.
752        assert_eq!(*log.lock(), ["/graphql", "/auth/refresh", "/graphql"]);
753    }
754
755    #[test]
756    fn unauthorised_without_a_session_says_why() {
757        let (base, log) = serve(signed_in_server);
758
759        match GraphQLClient::new(&base).execute("{ ok }", None) {
760            Err(GraphQLError::Unauthorized(msg)) => {
761                assert_eq!(msg, "missing or invalid Authorization header")
762            }
763            other => panic!("expected Unauthorized, got {other:?}"),
764        }
765        assert_eq!(*log.lock(), ["/graphql"]);
766    }
767}