Skip to main content

gqls/load/
mod.rs

1//! Schema ingestion: a source string is either an http(s) URL (introspect)
2//! or a path to an SDL file. Both produce the same [`SchemaRecord`] list, so
3//! nothing downstream cares which it was. When no source is given,
4//! [`discover`] finds a schema in the current directory tree.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Result};
9
10use crate::model::SchemaRecord;
11
12pub mod introspect;
13pub mod record_cache;
14pub mod sdl;
15
16/// Options that shape loading. Only URL introspection and SDL files consult
17/// them; introspection JSON files ignore them.
18#[derive(Default)]
19pub struct LoadOptions {
20    /// Extra request headers for URL introspection, as `(name, value)` — e.g. an
21    /// `Authorization` token for an auth-gated endpoint.
22    pub headers: Vec<(String, String)>,
23    /// Bypass the introspection response and parsed-record caches.
24    pub refresh: bool,
25}
26
27/// Load a schema from a file path or an http(s) URL and flatten it to records.
28pub fn load(source: &str, opts: &LoadOptions) -> Result<Vec<SchemaRecord>> {
29    if source.starts_with("http://") || source.starts_with("https://") {
30        introspect::from_url(source, opts)
31    } else if source.ends_with(".json") {
32        introspect::from_json_file(source, opts)
33    } else {
34        let text = std::fs::read_to_string(source).map_err(|e| anyhow!("reading {source}: {e}"))?;
35        if !opts.refresh {
36            if let Some(records) = record_cache::load(text.as_bytes()) {
37                return Ok(records);
38            }
39        }
40        let records = sdl::from_sdl(&text)?;
41        record_cache::store(text.as_bytes(), &records);
42        Ok(records)
43    }
44}
45
46const NOISE_DIRS: &[&str] = &[
47    ".git",
48    "node_modules",
49    "target",
50    "vendor",
51    "tmp",
52    "dist",
53    "build",
54    ".venv",
55];
56const MAX_DEPTH: usize = 12;
57
58/// Find a schema when none was passed: walk the current directory tree for
59/// schema *documents* (not operation docs), preferring `.graphqls`, then a
60/// `schema.*` file, then any `.graphql`/`.gql` whose contents look like SDL.
61/// Nearest (shallowest) wins; other candidates elsewhere are reported.
62pub fn discover() -> Result<String> {
63    let root = std::env::current_dir()?;
64    let mut found: Vec<Candidate> = Vec::new();
65    walk(&root, 0, &mut found);
66
67    if found.is_empty() {
68        bail!(
69            "no GraphQL schema found under {} — pass a .graphql file or an http(s) URL",
70            root.display()
71        );
72    }
73
74    found.sort_by(|a, b| {
75        // a `supergraph*` schema wins outright (the composed graph); otherwise
76        // nearest-and-most-canonical by tier, then depth, then path.
77        b.supergraph
78            .cmp(&a.supergraph)
79            .then(a.tier.cmp(&b.tier))
80            .then(a.depth.cmp(&b.depth))
81            .then(a.path.cmp(&b.path))
82    });
83    let chosen = found.remove(0);
84
85    let elsewhere = found
86        .iter()
87        .filter(|c| c.path.parent() != chosen.path.parent())
88        .count();
89    crate::status!("using schema {}", rel(&root, &chosen.path));
90    if elsewhere > 0 {
91        crate::status!(
92            "{elsewhere} other schema file(s) found elsewhere — pass a path to pick one"
93        );
94    }
95
96    Ok(chosen.path.to_string_lossy().into_owned())
97}
98
99struct Candidate {
100    path: PathBuf,
101    depth: usize,
102    /// Lower = more likely to be *the* schema.
103    tier: u8,
104    /// A `supergraph*`-named schema — the composed graph in a federated
105    /// monorepo, preferred when several candidates exist.
106    supergraph: bool,
107}
108
109fn walk(dir: &Path, depth: usize, out: &mut Vec<Candidate>) {
110    if depth > MAX_DEPTH {
111        return;
112    }
113    let Ok(entries) = std::fs::read_dir(dir) else {
114        return;
115    };
116    for entry in entries.flatten() {
117        let Ok(ft) = entry.file_type() else { continue };
118        let path = entry.path();
119        if ft.is_dir() {
120            let name = entry.file_name();
121            let name = name.to_string_lossy();
122            if name.starts_with('.') || NOISE_DIRS.contains(&name.as_ref()) {
123                continue;
124            }
125            walk(&path, depth + 1, out);
126        } else if ft.is_file() {
127            if let Some(tier) = classify(&path) {
128                let supergraph = path
129                    .file_name()
130                    .is_some_and(|n| n.to_string_lossy().to_lowercase().starts_with("supergraph"));
131                out.push(Candidate {
132                    path,
133                    depth,
134                    tier,
135                    supergraph,
136                });
137            }
138        }
139    }
140}
141
142/// The schema-source tier of a file (lower = better), or `None` if it isn't
143/// a schema document.
144fn classify(path: &Path) -> Option<u8> {
145    let name = path.file_name()?.to_string_lossy().to_lowercase();
146    if name.ends_with(".graphqls") {
147        return Some(0);
148    }
149    let sdl_ext = name.ends_with(".graphql") || name.ends_with(".gql");
150    if sdl_ext && name.starts_with("schema.") {
151        return Some(1);
152    }
153    if name.ends_with(".json") && sniff_is_introspection(path) {
154        return Some(2);
155    }
156    if sdl_ext && sniff_is_schema(path) {
157        return Some(3);
158    }
159    None
160}
161
162/// Cheap check that a `.json` file is a GraphQL introspection dump — the marker
163/// sits in the first few KB, so we don't read a multi-MB file to reject it.
164fn sniff_is_introspection(path: &Path) -> bool {
165    use std::io::Read;
166    let Ok(f) = std::fs::File::open(path) else {
167        return false;
168    };
169    let mut head = [0u8; 4096];
170    let n = std::io::BufReader::new(f).read(&mut head).unwrap_or(0);
171    let s = String::from_utf8_lossy(&head[..n]);
172    s.contains("__schema") || s.contains("\"queryType\"")
173}
174
175/// Cheap check that a `.graphql` file is SDL (type definitions) rather than an
176/// operation document (`query`/`mutation` blocks) — avoids a full parse of the
177/// many query files a project may carry.
178fn sniff_is_schema(path: &Path) -> bool {
179    let Ok(content) = std::fs::read_to_string(path) else {
180        return false;
181    };
182    content.lines().any(|l| {
183        let t = l.trim_start();
184        t.starts_with("type ")
185            || t.starts_with("interface ")
186            || t.starts_with("input ")
187            || t.starts_with("enum ")
188            || t.starts_with("scalar ")
189            || t.starts_with("union ")
190            || t.starts_with("directive ")
191            || t.starts_with("schema ")
192            || t.starts_with("schema{")
193    })
194}
195
196fn rel(root: &Path, p: &Path) -> String {
197    p.strip_prefix(root)
198        .unwrap_or(p)
199        .to_string_lossy()
200        .into_owned()
201}