Skip to main content

gqls/load/
introspect.rs

1//! Load a schema by introspection — from a live endpoint (POST the standard
2//! introspection query) or a local introspection JSON dump. Both flatten the
3//! `__schema` payload into the same [`SchemaRecord`]s that SDL produces.
4
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use anyhow::{anyhow, bail, Context, Result};
12use serde_json::Value;
13
14use super::LoadOptions;
15use crate::model::{Kind, Roots, SchemaRecord};
16
17/// Overall per-request deadline for live introspection (connect + read) — a
18/// hung or slow endpoint fails instead of blocking gqls forever.
19const TIMEOUT_SECS: u64 = 30;
20/// Default introspection-response cache lifetime for remote endpoints; see [`ttl`].
21const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60);
22
23/// POST the introspection query to `url` and flatten the result. Honors
24/// `opts.headers` (e.g. an `Authorization` token) and a TTL response cache
25/// (1h for remote endpoints, never for localhost) so repeated queries against a
26/// remote endpoint don't refetch all day.
27pub fn from_url(url: &str, opts: &LoadOptions) -> Result<Vec<SchemaRecord>> {
28    let raw = fetch_or_cached(url, opts)?;
29    let body: Value = serde_json::from_slice(&raw)
30        .with_context(|| format!("parsing introspection response from {url}"))?;
31
32    // Only a non-empty errors array is a real failure — many servers send
33    // `"errors": null` or `[]` alongside a valid `data`.
34    if let Some(errors) = body
35        .get("errors")
36        .filter(|e| !e.is_null() && !is_empty_array(e))
37    {
38        bail!("introspection returned errors: {errors}");
39    }
40    let schema = body
41        .pointer("/data/__schema")
42        .ok_or_else(|| anyhow!("no data.__schema in response from {url}"))?;
43    from_introspection(schema)
44}
45
46/// The raw introspection response for `url` — a cached copy if one exists and is
47/// younger than the [`ttl`], otherwise a fresh POST (which is then cached).
48/// `opts.refresh` skips the cache read and forces a live fetch.
49fn fetch_or_cached(url: &str, opts: &LoadOptions) -> Result<Vec<u8>> {
50    let ttl = ttl(url);
51    // A zero TTL (localhost, or GQLS_INTROSPECT_TTL=0) means no caching at all —
52    // neither read nor write, so a schema you're actively editing is never stale.
53    let path = (!ttl.is_zero()).then(|| cache_path(url)).flatten();
54    if !opts.refresh {
55        if let Some(p) = path.as_deref() {
56            if let Some(bytes) = read_if_fresh(p, ttl) {
57                crate::detail!("introspection cache hit: {}", p.display());
58                return Ok(bytes);
59            }
60        }
61    }
62    crate::detail!("introspecting {url} (live)");
63    let bytes = fetch(url, &opts.headers)?;
64    if let Some(p) = path.as_deref() {
65        if let Some(dir) = p.parent() {
66            let _ = std::fs::create_dir_all(dir);
67        }
68        let _ = std::fs::write(p, &bytes);
69    }
70    Ok(bytes)
71}
72
73fn fetch(url: &str, headers: &[(String, String)]) -> Result<Vec<u8>> {
74    let mut req = ureq::post(url)
75        .timeout(Duration::from_secs(TIMEOUT_SECS))
76        .set("Content-Type", "application/json")
77        .set("Accept", "application/json");
78    for (name, value) in headers {
79        req = req.set(name, value);
80    }
81    let resp = req
82        .send_json(serde_json::json!({ "query": INTROSPECTION_QUERY }))
83        .map_err(|e| anyhow!("introspecting {url}: {e}"))?;
84    let mut buf = Vec::new();
85    resp.into_reader()
86        .read_to_end(&mut buf)
87        .with_context(|| format!("reading introspection response from {url}"))?;
88    Ok(buf)
89}
90
91/// Cache file for a URL's introspection response (keyed by the URL).
92fn cache_path(url: &str) -> Option<PathBuf> {
93    let mut h = DefaultHasher::new();
94    url.hash(&mut h);
95    Some(cache_dir()?.join(format!("{:016x}.json", h.finish())))
96}
97
98fn cache_dir() -> Option<PathBuf> {
99    Some(crate::paths::cache_dir()?.join("introspect"))
100}
101
102/// The cached bytes if the file is younger than `ttl`, else `None`.
103fn read_if_fresh(path: &Path, ttl: Duration) -> Option<Vec<u8>> {
104    let age = std::fs::metadata(path)
105        .ok()?
106        .modified()
107        .ok()?
108        .elapsed()
109        .ok()?;
110    (age <= ttl).then(|| std::fs::read(path).ok()).flatten()
111}
112
113/// Effective cache lifetime for `url`: `GQLS_INTROSPECT_TTL` (seconds) if set,
114/// else zero for localhost — you're likely editing that schema, so always fetch
115/// fresh — and [`DEFAULT_TTL`] (1h) for remote endpoints.
116fn ttl(url: &str) -> Duration {
117    if let Some(secs) = std::env::var("GQLS_INTROSPECT_TTL")
118        .ok()
119        .and_then(|s| s.parse::<u64>().ok())
120    {
121        return Duration::from_secs(secs);
122    }
123    if is_localhost(url) {
124        return Duration::ZERO;
125    }
126    DEFAULT_TTL
127}
128
129/// Whether `url` points at the local machine (loopback), where the schema is
130/// probably under active development and should never be served from cache.
131fn is_localhost(url: &str) -> bool {
132    let after_scheme = url.split_once("://").map_or(url, |(_, r)| r);
133    let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
134    let host_port = authority.rsplit('@').next().unwrap_or(authority);
135    // Pull the host out of `host:port` / `[ipv6]:port` / bare host.
136    let host = if let Some(rest) = host_port.strip_prefix('[') {
137        rest.split(']').next().unwrap_or(rest)
138    } else {
139        host_port.split(':').next().unwrap_or(host_port)
140    };
141    let host = host.to_ascii_lowercase();
142    host == "localhost"
143        || host.ends_with(".localhost")
144        || host == "::1"
145        || host == "0.0.0.0"
146        || host.starts_with("127.")
147}
148
149/// Delete all cached introspection responses; returns how many were removed.
150pub fn clear_cache() -> usize {
151    let Some(dir) = cache_dir() else { return 0 };
152    let Ok(rd) = std::fs::read_dir(&dir) else {
153        return 0;
154    };
155    rd.flatten()
156        .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
157        .filter(|e| std::fs::remove_file(e.path()).is_ok())
158        .count()
159}
160
161/// Load a local introspection JSON dump — accepts `{data:{__schema}}`,
162/// `{__schema}`, or the bare schema object.
163pub fn from_json_file(path: &str) -> Result<Vec<SchemaRecord>> {
164    let text = std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
165    let v: Value = serde_json::from_str(&text).with_context(|| format!("parsing {path}"))?;
166    let schema = v
167        .pointer("/data/__schema")
168        .or_else(|| v.get("__schema"))
169        .unwrap_or(&v);
170    if schema.get("types").is_none() {
171        bail!("{path} is not a GraphQL introspection dump (no __schema.types)");
172    }
173    from_introspection(schema)
174}
175
176fn from_introspection(schema: &Value) -> Result<Vec<SchemaRecord>> {
177    let roots = Roots {
178        query: root_name(schema, "queryType"),
179        mutation: root_name(schema, "mutationType"),
180        subscription: root_name(schema, "subscriptionType"),
181    };
182
183    let mut out = Vec::new();
184    for t in array(schema, "types") {
185        emit_type(t, &roots, &mut out);
186    }
187    for d in array(schema, "directives") {
188        let name = str_field(d, "name");
189        if name.is_empty() {
190            continue;
191        }
192        out.push(SchemaRecord {
193            path: format!("@{name}"),
194            name,
195            kind: Kind::Directive,
196            parent: None,
197            type_ref: None,
198            args: args_of(d),
199            description: opt_str(d, "description"),
200            deprecated: None,
201            directives: Vec::new(),
202        });
203    }
204    Ok(out)
205}
206
207fn root_name(schema: &Value, key: &str) -> Option<String> {
208    schema.get(key)?.get("name")?.as_str().map(str::to_string)
209}
210
211fn emit_type(t: &Value, roots: &Roots, out: &mut Vec<SchemaRecord>) {
212    let name = str_field(t, "name");
213    if name.is_empty() || name.starts_with("__") {
214        return; // skip introspection meta types (__Type, __Schema, ...)
215    }
216    let type_kind = match str_field(t, "kind").as_str() {
217        "OBJECT" => Kind::Object,
218        "INTERFACE" => Kind::Interface,
219        "UNION" => Kind::Union,
220        "ENUM" => Kind::Enum,
221        "INPUT_OBJECT" => Kind::InputObject,
222        "SCALAR" => Kind::Scalar,
223        _ => return,
224    };
225
226    out.push(SchemaRecord {
227        path: name.clone(),
228        name: name.clone(),
229        kind: type_kind,
230        parent: None,
231        type_ref: None,
232        args: Vec::new(),
233        description: opt_str(t, "description"),
234        deprecated: None,
235        directives: Vec::new(),
236    });
237
238    match type_kind {
239        Kind::Object | Kind::Interface => {
240            let field_kind = roots.field_kind(&name);
241            for f in array(t, "fields") {
242                let fname = str_field(f, "name");
243                out.push(SchemaRecord {
244                    path: format!("{name}.{fname}"),
245                    name: fname,
246                    kind: field_kind,
247                    parent: Some(name.clone()),
248                    type_ref: f.get("type").map(render_type),
249                    args: args_of(f),
250                    description: opt_str(f, "description"),
251                    deprecated: deprecation(f),
252                    directives: Vec::new(),
253                });
254            }
255        }
256        Kind::InputObject => {
257            for f in array(t, "inputFields") {
258                let fname = str_field(f, "name");
259                out.push(SchemaRecord {
260                    path: format!("{name}.{fname}"),
261                    name: fname,
262                    kind: Kind::InputField,
263                    parent: Some(name.clone()),
264                    type_ref: f.get("type").map(render_type),
265                    args: Vec::new(),
266                    description: opt_str(f, "description"),
267                    deprecated: deprecation(f),
268                    directives: Vec::new(),
269                });
270            }
271        }
272        Kind::Enum => {
273            for v in array(t, "enumValues") {
274                let vname = str_field(v, "name");
275                out.push(SchemaRecord {
276                    path: format!("{name}.{vname}"),
277                    name: vname,
278                    kind: Kind::EnumValue,
279                    parent: Some(name.clone()),
280                    type_ref: None,
281                    args: Vec::new(),
282                    description: opt_str(v, "description"),
283                    deprecated: deprecation(v),
284                    directives: Vec::new(),
285                });
286            }
287        }
288        _ => {}
289    }
290}
291
292/// Render an introspection type-ref (the `ofType` chain) like SDL: `[User!]!`.
293fn render_type(t: &Value) -> String {
294    match t.get("kind").and_then(Value::as_str) {
295        Some("NON_NULL") => format!("{}!", t.get("ofType").map(render_type).unwrap_or_default()),
296        Some("LIST") => format!("[{}]", t.get("ofType").map(render_type).unwrap_or_default()),
297        _ => t
298            .get("name")
299            .and_then(Value::as_str)
300            .unwrap_or("?")
301            .to_string(),
302    }
303}
304
305fn args_of(f: &Value) -> Vec<String> {
306    array(f, "args")
307        .iter()
308        .map(|a| {
309            let n = str_field(a, "name");
310            let ty = a.get("type").map(render_type).unwrap_or_default();
311            format!("{n}: {ty}")
312        })
313        .collect()
314}
315
316fn deprecation(v: &Value) -> Option<String> {
317    (v.get("isDeprecated").and_then(Value::as_bool) == Some(true))
318        .then(|| opt_str(v, "deprecationReason").unwrap_or_else(|| "deprecated".into()))
319}
320
321fn str_field(v: &Value, key: &str) -> String {
322    v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
323}
324
325fn opt_str(v: &Value, key: &str) -> Option<String> {
326    v.get(key)
327        .and_then(Value::as_str)
328        .filter(|s| !s.is_empty())
329        .map(str::to_string)
330}
331
332fn array<'a>(v: &'a Value, key: &str) -> &'a [Value] {
333    v.get(key)
334        .and_then(Value::as_array)
335        .map(Vec::as_slice)
336        .unwrap_or(&[])
337}
338
339fn is_empty_array(v: &Value) -> bool {
340    v.as_array().is_some_and(|a| a.is_empty())
341}
342
343/// The standard introspection query (7-level `ofType` nesting covers any
344/// realistic list/non-null wrapping).
345const INTROSPECTION_QUERY: &str = r#"
346query IntrospectionQuery {
347  __schema {
348    queryType { name }
349    mutationType { name }
350    subscriptionType { name }
351    types { ...FullType }
352    directives { name description args { ...InputValue } }
353  }
354}
355fragment FullType on __Type {
356  kind name description
357  fields(includeDeprecated: true) {
358    name description
359    args { ...InputValue }
360    type { ...TypeRef }
361    isDeprecated deprecationReason
362  }
363  inputFields { ...InputValue }
364  enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
365}
366fragment InputValue on __InputValue { name description type { ...TypeRef } }
367fragment TypeRef on __Type {
368  kind name
369  ofType { kind name ofType { kind name ofType { kind name ofType { kind name
370  ofType { kind name ofType { kind name ofType { kind name } } } } } } }
371}
372"#;
373
374#[cfg(test)]
375mod tests {
376    use super::is_localhost;
377
378    #[test]
379    fn localhost_urls_are_detected() {
380        for url in [
381            "http://localhost:4000/graphql",
382            "http://127.0.0.1:8080/",
383            "http://127.0.0.2/graphql",
384            "http://[::1]:4000/graphql",
385            "http://0.0.0.0:3000",
386            "https://api.localhost/graphql",
387            "http://user:pass@localhost:4000/",
388        ] {
389            assert!(is_localhost(url), "{url} should be localhost");
390        }
391    }
392
393    #[test]
394    fn remote_urls_are_not_localhost() {
395        for url in [
396            "https://countries.trevorblades.com/",
397            "https://api.github.com/graphql",
398            "https://mylocalhost.com/graphql",
399            "http://localhost.evil.com/graphql",
400        ] {
401            assert!(!is_localhost(url), "{url} should not be localhost");
402        }
403    }
404}