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