Files
Loki/rust/loki-rs/tests/common/fakego.rs
T
Claude 1e5f0904f2 Lot 13 : mémoire tenue par la façade (LOKI_MEM=rust)
La façade sert l'éditeur des pages mémoire (lecture, enregistrement,
renommage, suppression, déplacement vers un autre projet, index MEMORY.md
tenu par le code), le panneau des trackers, le mode mémoire du projet
actif et les interrupteurs du mode agent, quand LOKI_MEM=rust
(expérimental ; le Go reste le défaut).

Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre
(.bak, temporaire synchronisé puis renommé, relecture vérifiée), les
trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la
façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et
n'écrit jamais en clair quand la mémoire est verrouillée.

gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF,
erreurs du scanner et de type, structs anonymes) pour renvoyer au client
les mêmes messages que le Go ; il servira aux lots suivants.

Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent
par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go.

Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ;
parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de
route avec le disque et la base après chacun) → tests/memory.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:33:22 +00:00

662 lines
25 KiB
Rust

//! Faux serveur Go : API interne (`/internal/v1/auth`, `/internal/v1/store`,
//! `/internal/v1/store/all`) sur un store en mémoire, et `go:<chemin>` pour
//! toute autre route (ce qui prouve qu'elle a été relayée).
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode, service::service_fn};
use hyper_util::rt::TokioIo;
use std::collections::{BTreeMap, HashMap};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
pub const JETON: &str = "0123456789abcdef0123456789abcdef";
/// Scellement simulé par le faux Go.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CryptMode {
/// « SCELLE: » + clair (historique des essais de la conversation).
#[default]
Scelle,
/// Chiffrement coupé : rien ne change.
Plain,
/// Magic des pages mémoire (`LOKIMEMv1`) + clair.
Magic,
}
#[derive(Default)]
pub struct FakeGo {
/// bucket → clé → valeur.
pub store: Mutex<HashMap<String, BTreeMap<String, Vec<u8>>>>,
/// Base illisible : toute l'API interne répond 503.
pub broken: Mutex<bool>,
/// Chemins relayés (hors API interne).
pub relayed: Mutex<Vec<String>>,
/// /v1 : port du faux moteur, second slot en service, refus à renvoyer
/// par begin, journal des begin/end.
pub engine_port: Mutex<u16>,
pub side_live: Mutex<bool>,
pub refuse: Mutex<Option<serde_json::Value>>,
pub oai_log: Mutex<Vec<String>>,
/// Mémoire verrouillée : seal/open répondent 423.
pub locked: Mutex<bool>,
/// Bail du moteur : bail courant, stop signalé, détenteur Go, journal.
pub lease: Mutex<Option<String>>,
pub lease_stop: Mutex<bool>,
pub go_busy: Mutex<Option<String>>,
pub lease_log: Mutex<Vec<String>>,
/// Résultat imposé à tout outil (sinon « exec:<nom> »), et archive recall.
pub tool_result: Mutex<Option<String>>,
pub recall: Mutex<Vec<String>>,
/// Discussion active annoncée et discussions supprimées.
pub conv_log: Mutex<Vec<String>>,
/// Tours : débuts, vues et fins demandés.
pub turn_log: Mutex<Vec<String>>,
/// Adresse d'écoute du faux Go (pour un client `Store` direct).
pub backend: Mutex<Option<SocketAddr>>,
/// Contexte des outils exécutés par la façade (None : 404, le Go les
/// exécute), et journal du suivi (lectures, écritures, contrôles).
pub tool_ctx: Mutex<Option<serde_json::Value>>,
pub tracker_log: Mutex<Vec<String>>,
/// Tours en mode Code, et outils exécutés (« nom rôle »). L'outil
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
pub code: Mutex<bool>,
pub tool_log: Mutex<Vec<String>>,
/// Le Go garde la conversation (raison), sinon la façade la tient.
pub go_keeps_chat: Mutex<Option<String>>,
/// Réponses imposées de /turn/start et /turn/view (journaux de référence
/// du Go), et résultat par outil.
pub turn_start: Mutex<Option<serde_json::Value>>,
pub turn_view: Mutex<Option<serde_json::Value>>,
pub turn_verifier: Mutex<Option<serde_json::Value>>,
pub tool_results: Mutex<HashMap<String, String>>,
/// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut).
pub crypt_mode: Mutex<CryptMode>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
pub conv_workspace: Mutex<Option<serde_json::Value>>,
}
impl FakeGo {
pub fn set(&self, bucket: &str, key: &str, val: &str) {
self.store
.lock()
.unwrap()
.entry(bucket.into())
.or_default()
.insert(key.into(), val.as_bytes().to_vec());
}
pub fn get(&self, bucket: &str, key: &str) -> Option<String> {
let s = self.store.lock().unwrap();
s.get(bucket)?
.get(key)
.map(|v| String::from_utf8_lossy(v).into_owned())
}
}
fn query(req: &Request<Incoming>) -> HashMap<String, String> {
req.uri()
.query()
.unwrap_or("")
.split('&')
.filter_map(|kv| kv.split_once('='))
.map(|(k, v)| (k.to_string(), decode(v)))
.collect()
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let h = std::str::from_utf8(&b[i + 1..i + 3]).unwrap();
out.push(u8::from_str_radix(h, 16).unwrap());
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).unwrap()
}
fn resp(code: u16, body: impl Into<Bytes>) -> Response<Full<Bytes>> {
let mut r = Response::new(Full::new(body.into()));
*r.status_mut() = StatusCode::from_u16(code).unwrap();
r
}
async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>> {
let path = req.uri().path().to_string();
if !path.starts_with("/internal/") {
st.relayed.lock().unwrap().push(path.clone());
if path == "/api/chat/mode" && req.method() == Method::POST {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
return resp(
200,
serde_json::json!({"ok": true, "mode": v["mode"]}).to_string(),
);
}
return resp(200, format!("go:{path}"));
}
if req.headers().get("x-loki-internal").map(|v| v.as_bytes()) != Some(JETON.as_bytes()) {
return resp(404, "");
}
if *st.broken.lock().unwrap() {
return resp(503, r#"{"error":"base illisible"}"#);
}
let q = query(&req);
let method = req.method().clone();
match path.as_str() {
"/internal/v1/auth" => {
let key = st.get("state", "web_key").unwrap_or_default();
resp(200, format!(r#"{{"web_key":"{key}","version":"0.15.0"}}"#))
}
"/internal/v1/crypt/seal" | "/internal/v1/crypt/open" => {
if *st.locked.lock().unwrap() {
return resp(423, r#"{"error":"mémoire verrouillée"}"#);
}
let body = req.into_body().collect().await.unwrap().to_bytes();
match *st.crypt_mode.lock().unwrap() {
// Chiffrement coupé : clair rendu tel quel.
CryptMode::Plain => return resp(200, body),
// Enveloppe au magic des pages (`LOKIMEMv1`).
CryptMode::Magic => {
return if path.ends_with("seal") {
let mut out = b"LOKIMEMv1".to_vec();
out.extend_from_slice(&body);
resp(200, out)
} else {
resp(
200,
body.strip_prefix(b"LOKIMEMv1").unwrap_or(&body).to_vec(),
)
};
}
CryptMode::Scelle => {}
}
if path.ends_with("seal") {
let mut out = b"SCELLE:".to_vec();
out.extend_from_slice(&body);
resp(200, out)
} else {
resp(200, body.strip_prefix(b"SCELLE:").unwrap_or(&body).to_vec())
}
}
"/internal/v1/engine/lease" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
if let Some(why) = st.go_busy.lock().unwrap().clone() {
let out = serde_json::json!({"error": why, "task_id": "t1", "task_name": "veille"});
return resp(409, out.to_string());
}
let mut lease = st.lease.lock().unwrap();
if lease.is_some() {
return resp(409, r#"{"error":"génération en cours"}"#);
}
let n = st.lease_log.lock().unwrap().len();
let id = format!("bail{n}");
*lease = Some(id.clone());
st.lease_log
.lock()
.unwrap()
.push(format!("acquire {}", v["ttl_ms"]));
resp(
200,
serde_json::json!({"lease": id, "ttl_ms": v["ttl_ms"]}).to_string(),
)
}
"/internal/v1/engine/lease/renew" | "/internal/v1/engine/lease/release" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let id = v["lease"].as_str().unwrap_or_default().to_string();
let mut lease = st.lease.lock().unwrap();
let held = lease.as_deref() == Some(id.as_str());
if path.ends_with("release") {
st.lease_log.lock().unwrap().push(format!("release {id}"));
if held {
*lease = None;
}
return resp(204, "");
}
st.lease_log.lock().unwrap().push(format!("renew {id}"));
if !held {
return resp(410, r#"{"error":"bail perdu"}"#);
}
let stop = *st.lease_stop.lock().unwrap();
resp(200, serde_json::json!({"stop": stop}).to_string())
}
"/internal/v1/recall/archive" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let mut r = st.recall.lock().unwrap();
r.push(v["content"].as_str().unwrap_or_default().to_string());
resp(
200,
serde_json::json!({"id": format!("r{}", r.len())}).to_string(),
)
}
"/internal/v1/conv/active" | "/internal/v1/conv/dropped" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let what = path.rsplit('/').next().unwrap();
st.conv_log
.lock()
.unwrap()
.push(format!("{what} {}", v["id"].as_str().unwrap_or_default()));
resp(204, "")
}
"/internal/v1/turn/start" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let msg = v["message"].as_str().unwrap_or_default().to_string();
st.turn_log.lock().unwrap().push(format!("start {msg}"));
if msg.trim().is_empty() {
return resp(400, r#"{"error":"message vide"}"#);
}
if let Some(v) = st.turn_start.lock().unwrap().clone() {
return resp(200, v.to_string());
}
let code = *st.code.lock().unwrap();
let caps = serde_json::json!({"Agent": true, "Mem": "off", "Code": code, "Role": ""});
let out = serde_json::json!({
"files": [], "caps": caps, "turn_caps": caps,
"content": msg, "model": "m1", "code_hint": false,
});
resp(200, out.to_string())
}
"/internal/v1/turn/view" => {
st.turn_log.lock().unwrap().push("view".into());
if let Some(v) = st.turn_view.lock().unwrap().clone() {
return resp(200, v.to_string());
}
let out = serde_json::json!({
"head": [{"role": "system", "content": "Tu es Loki."}],
"prefix": "PRÉAMBULE",
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
});
resp(200, out.to_string())
}
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
None => resp(200, r#"{"holder":"rust"}"#),
Some(why) => resp(
200,
serde_json::json!({"holder": "go", "reason": why}).to_string(),
),
},
"/internal/v1/project/activate" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let slug = v["slug"].as_str().unwrap_or_default().to_string();
if slug.is_empty() || slug == "absent" {
return resp(400, r#"{"error":"projet introuvable"}"#);
}
st.conv_log.lock().unwrap().push(format!("project {slug}"));
st.set("state", "active_project", &slug);
resp(204, "")
}
"/internal/v1/turn/verifier" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
st.turn_log.lock().unwrap().push("verifier".into());
if let Some(out) = st.turn_verifier.lock().unwrap().clone() {
return resp(200, out.to_string());
}
let mut caps = v["caps"].clone();
caps["Role"] = "verifier".into();
let out = serde_json::json!({
"system": "VÉRIFICATEUR",
"tools": [{"type": "function", "function": {"name": "criteria", "description": "", "parameters": {"type": "object"}}}],
"caps": caps,
});
resp(200, out.to_string())
}
"/internal/v1/turn/done" => {
st.turn_log.lock().unwrap().push("done".into());
resp(204, "")
}
"/internal/v1/engine/stop" => {
st.lease_log.lock().unwrap().push("stop".into());
*st.go_busy.lock().unwrap() = None;
resp(204, "")
}
"/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() {
Some(v) => resp(200, v.to_string()),
None => resp(404, "404 page not found"),
},
"/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() {
Some(v) => resp(200, v.to_string()),
None => resp(404, "404 page not found"),
},
"/internal/v1/tool/tracker" | "/internal/v1/tool/wrote" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let op = v["op"].as_str().unwrap_or("wrote").to_string();
let path = v["path"].as_str().unwrap_or("").to_string();
st.tracker_log.lock().unwrap().push(format!("{op} {path}"));
let out = if op == "check" {
serde_json::json!({ "refusal": "" })
} else {
serde_json::json!({ "lsp": "" })
};
resp(200, out.to_string())
}
"/internal/v1/tool/exec" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let name = v["name"].as_str().unwrap_or_default();
st.tool_log.lock().unwrap().push(format!(
"{name} {}",
v["caps"]["Role"].as_str().unwrap_or_default()
));
if name == "criteria" && v["args"]["action"] == "set" {
let mut chat = st.store.lock().unwrap();
for (k, val) in chat.entry("chat".into()).or_default().iter_mut() {
if !k.starts_with("crit:") {
continue;
}
let mut list: Vec<serde_json::Value> =
serde_json::from_slice(val).unwrap_or_default();
for c in &mut list {
if c["id"] == v["args"]["id"] {
c["status"] = v["args"]["status"].clone();
}
}
*val = serde_json::to_vec(&list).unwrap();
}
}
let by_name = st.tool_results.lock().unwrap().get(name).cloned();
let result = by_name
.or_else(|| st.tool_result.lock().unwrap().clone())
.unwrap_or_else(|| format!("exec:{}", v["name"].as_str().unwrap()));
let out = serde_json::json!({
"label": v["args"]["command"],
"result": result,
"caps_agent": v["caps"]["Agent"],
});
resp(200, out.to_string())
}
"/internal/v1/oai/route" => {
let key = st.get("state", "api_key").unwrap_or_default();
let v = serde_json::json!({
"port": *st.engine_port.lock().unwrap(),
"strata": false,
"key": key,
"key_ok": true,
"max_body": 1024,
"side_live": *st.side_live.lock().unwrap(),
});
resp(200, v.to_string())
}
"/internal/v1/oai/begin" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
st.oai_log.lock().unwrap().push(format!(
"begin read={} side={} post_v1={}",
v["read"], v["side"], v["post_v1"]
));
if let Some(r) = st.refuse.lock().unwrap().clone() {
return resp(200, serde_json::json!({ "refuse": r }).to_string());
}
resp(200, r#"{"ticket":7}"#)
}
"/internal/v1/oai/end" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
st.oai_log
.lock()
.unwrap()
.push(format!("end {}", v["ticket"]));
resp(204, "")
}
"/internal/v1/store/all" => {
let s = st.store.lock().unwrap();
let m: BTreeMap<String, String> = s
.get(&q["bucket"])
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned()))
.collect()
})
.unwrap_or_default();
resp(200, serde_json::to_string(&m).unwrap())
}
"/internal/v1/store" => {
let (bucket, key) = (q["bucket"].clone(), q["key"].clone());
match method {
Method::GET => match st
.store
.lock()
.unwrap()
.get(&bucket)
.and_then(|b| b.get(&key))
{
Some(v) => resp(200, v.clone()),
None => resp(404, ""),
},
Method::PUT => {
let body = req.into_body().collect().await.unwrap().to_bytes();
st.store
.lock()
.unwrap()
.entry(bucket)
.or_default()
.insert(key, body.to_vec());
resp(204, "")
}
Method::DELETE => {
if let Some(b) = st.store.lock().unwrap().get_mut(&bucket) {
b.remove(&key);
}
resp(204, "")
}
_ => resp(405, ""),
}
}
_ => resp(404, ""),
}
}
/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade,
/// état du faux Go, garde d'arrêt).
pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_with(home, Opts::default()).await;
(f, st, tx)
}
/// Comme [`start`], avec les routes des modèles tenues par la façade
/// (`LOKI_MODELS=rust`) ; `hf` : adresse du faux Hugging Face.
pub async fn start_models(
home: std::path::PathBuf,
hf: &str,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let opts = Opts {
models_hf: Some(hf.to_string()),
..Opts::default()
};
let (f, st, tx, _) = start_with(home, opts).await;
(f, st, tx)
}
/// Comme [`start`], avec la dictée tenue par la façade (`LOKI_DICTATE=rust`).
pub async fn start_dictate(
home: std::path::PathBuf,
d: Arc<loki_rs::dictate::Dictation>,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let opts = Opts {
dictate: Some(d),
..Opts::default()
};
let (f, st, tx, _) = start_with(home, opts).await;
(f, st, tx)
}
/// Ce que la façade tient en plus du proxy.
#[derive(Default)]
pub struct Opts {
pub chat: bool,
pub engine_port: Option<u16>,
pub models_hf: Option<String>,
pub dictate: Option<Arc<loki_rs::dictate::Dictation>>,
pub settings: bool,
pub files: Option<Arc<loki_rs::convfiles::ConvFiles>>,
pub push: Option<Arc<loki_rs::push::Push>>,
pub mem: Option<Arc<loki_rs::memory::Memory>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
/// (`LOKI_CHAT=rust`) ; `engine_port` : port du faux moteur (config PORT).
pub async fn start_chat(
home: std::path::PathBuf,
engine_port: u16,
) -> (
SocketAddr,
Arc<FakeGo>,
oneshot::Sender<()>,
Arc<loki_rs::chat::generate::ChatService>,
) {
let (f, st, tx, chat) = start_with(
home,
Opts {
chat: true,
engine_port: Some(engine_port),
..Opts::default()
},
)
.await;
(f, st, tx, chat.unwrap())
}
pub async fn start_with(
home: std::path::PathBuf,
opts: Opts,
) -> (
SocketAddr,
Arc<FakeGo>,
oneshot::Sender<()>,
Option<Arc<loki_rs::chat::generate::ChatService>>,
) {
let st = Arc::new(FakeGo::default());
if let Some(p) = opts.engine_port {
st.set("config", "PORT", &p.to_string());
}
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let backend = l.local_addr().unwrap();
*st.backend.lock().unwrap() = Some(backend);
let st2 = st.clone();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let st = st2.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| {
let st = st.clone();
async move { Ok::<_, Infallible>(handle(st, req).await) }
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
let chat = if opts.chat {
use loki_rs::chat::{
conv::Conversation, generate::ChatService, host::ChatShared, sessions,
};
let store = loki_rs::store::Store::new(backend, JETON.into());
let s = sessions::Sessions::new(
store.clone(),
Conversation::new(),
sessions::GoSessionHooks(store.clone()),
);
s.load().await.unwrap();
let shared = ChatShared::for_sessions(&s, store, home.join("chatimg"));
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
} else {
None
};
let fl = TcpListener::bind("127.0.0.1:0").await.unwrap();
let facade = fl.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
let cfg = loki_rs::proxy::Config {
backend,
internal_token: JETON.into(),
host_env: loki_rs::auth::HostEnv::default(),
engine: loki_rs::engine::Paths {
home,
service: "loki-engine".into(),
},
chat: chat.clone(),
models: opts.models_hf.map(|hf| {
Arc::new(loki_rs::models::Models {
downloads: Arc::new(loki_rs::downloads::Downloads::new()),
hf: loki_rs::hf::HfClient::new(&hf),
})
}),
dictate: opts.dictate,
settings: opts.settings,
files: opts.files,
push: opts.push,
mem: opts.mem,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
}));
(facade, st, tx, chat)
}
/// Requête via la façade (corps JSON) : (code, corps).
pub async fn send(
facade: SocketAddr,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> (StatusCode, Bytes) {
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
let mut req = Request::builder()
.method(method)
.uri(format!("http://{facade}{path}"))
.header("content-type", "application/json");
for (k, v) in headers {
req = req.header(*k, *v);
}
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let r = client
.request(req.body(Full::new(Bytes::from(body.to_string()))).unwrap())
.await
.unwrap();
let (parts, body) = r.into_parts();
(parts.status, body.collect().await.unwrap().to_bytes())
}
/// GET via la façade : (code, en-têtes, corps).
pub async fn get(
facade: SocketAddr,
path: &str,
headers: &[(&str, &str)],
) -> (StatusCode, hyper::HeaderMap, Bytes) {
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
let mut req = Request::get(format!("http://{facade}{path}"));
for (k, v) in headers {
req = req.header(*k, *v);
}
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let r = client
.request(req.body(Full::new(Bytes::new())).unwrap())
.await
.unwrap();
let (parts, body) = r.into_parts();
(
parts.status,
parts.headers,
body.collect().await.unwrap().to_bytes(),
)
}