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