1use 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
17const TIMEOUT_SECS: u64 = 30;
20const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60);
22
23pub 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 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
46fn fetch_or_cached(url: &str, opts: &LoadOptions) -> Result<Vec<u8>> {
50 let ttl = ttl(url);
51 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
91fn 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
102fn 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
113fn 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
129fn 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 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
149pub 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
161pub 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; }
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
292fn 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
343const 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}