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