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 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 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
55fn fetch_or_cached(url: &str, opts: &LoadOptions) -> Result<Vec<u8>> {
59 let ttl = ttl(url);
60 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
100fn 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
111fn 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
122fn 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
138fn 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 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
158pub 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
170pub 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; }
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
309fn 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
360const 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}