use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use super::LoadOptions;
use crate::model::{Kind, Roots, SchemaRecord};
const TIMEOUT_SECS: u64 = 30;
const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
pub fn from_url(url: &str, opts: &LoadOptions) -> Result<Vec<SchemaRecord>> {
let raw = fetch_or_cached(url, opts)?;
let body: Value = serde_json::from_slice(&raw)
.with_context(|| format!("parsing introspection response from {url}"))?;
if let Some(errors) = body
.get("errors")
.filter(|e| !e.is_null() && !is_empty_array(e))
{
bail!("introspection returned errors: {errors}");
}
let schema = body
.pointer("/data/__schema")
.ok_or_else(|| anyhow!("no data.__schema in response from {url}"))?;
from_introspection(schema)
}
fn fetch_or_cached(url: &str, opts: &LoadOptions) -> Result<Vec<u8>> {
let path = cache_path(url);
if !opts.refresh {
if let Some(p) = path.as_deref() {
if let Some(bytes) = read_if_fresh(p, ttl()) {
crate::detail!("introspection cache hit: {}", p.display());
return Ok(bytes);
}
}
}
crate::detail!("introspecting {url} (live)");
let bytes = fetch(url, &opts.headers)?;
if let Some(p) = path.as_deref() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(p, &bytes);
}
Ok(bytes)
}
fn fetch(url: &str, headers: &[(String, String)]) -> Result<Vec<u8>> {
let mut req = ureq::post(url)
.timeout(Duration::from_secs(TIMEOUT_SECS))
.set("Content-Type", "application/json")
.set("Accept", "application/json");
for (name, value) in headers {
req = req.set(name, value);
}
let resp = req
.send_json(serde_json::json!({ "query": INTROSPECTION_QUERY }))
.map_err(|e| anyhow!("introspecting {url}: {e}"))?;
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.with_context(|| format!("reading introspection response from {url}"))?;
Ok(buf)
}
fn cache_path(url: &str) -> Option<PathBuf> {
let mut h = DefaultHasher::new();
url.hash(&mut h);
Some(cache_dir()?.join(format!("{:016x}.json", h.finish())))
}
fn cache_dir() -> Option<PathBuf> {
Some(crate::paths::cache_dir()?.join("introspect"))
}
fn read_if_fresh(path: &Path, ttl: Duration) -> Option<Vec<u8>> {
let age = std::fs::metadata(path)
.ok()?
.modified()
.ok()?
.elapsed()
.ok()?;
(age <= ttl).then(|| std::fs::read(path).ok()).flatten()
}
fn ttl() -> Duration {
std::env::var("GQLS_INTROSPECT_TTL")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map_or(DEFAULT_TTL, Duration::from_secs)
}
pub fn clear_cache() -> usize {
let Some(dir) = cache_dir() else { return 0 };
let Ok(rd) = std::fs::read_dir(&dir) else {
return 0;
};
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter(|e| std::fs::remove_file(e.path()).is_ok())
.count()
}
pub fn from_json_file(path: &str) -> Result<Vec<SchemaRecord>> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
let v: Value = serde_json::from_str(&text).with_context(|| format!("parsing {path}"))?;
let schema = v
.pointer("/data/__schema")
.or_else(|| v.get("__schema"))
.unwrap_or(&v);
if schema.get("types").is_none() {
bail!("{path} is not a GraphQL introspection dump (no __schema.types)");
}
from_introspection(schema)
}
fn from_introspection(schema: &Value) -> Result<Vec<SchemaRecord>> {
let roots = Roots {
query: root_name(schema, "queryType"),
mutation: root_name(schema, "mutationType"),
subscription: root_name(schema, "subscriptionType"),
};
let mut out = Vec::new();
for t in array(schema, "types") {
emit_type(t, &roots, &mut out);
}
for d in array(schema, "directives") {
let name = str_field(d, "name");
if name.is_empty() {
continue;
}
out.push(SchemaRecord {
path: format!("@{name}"),
name,
kind: Kind::Directive,
parent: None,
type_ref: None,
args: args_of(d),
description: opt_str(d, "description"),
deprecated: None,
directives: Vec::new(),
});
}
Ok(out)
}
fn root_name(schema: &Value, key: &str) -> Option<String> {
schema.get(key)?.get("name")?.as_str().map(str::to_string)
}
fn emit_type(t: &Value, roots: &Roots, out: &mut Vec<SchemaRecord>) {
let name = str_field(t, "name");
if name.is_empty() || name.starts_with("__") {
return; }
let type_kind = match str_field(t, "kind").as_str() {
"OBJECT" => Kind::Object,
"INTERFACE" => Kind::Interface,
"UNION" => Kind::Union,
"ENUM" => Kind::Enum,
"INPUT_OBJECT" => Kind::InputObject,
"SCALAR" => Kind::Scalar,
_ => return,
};
out.push(SchemaRecord {
path: name.clone(),
name: name.clone(),
kind: type_kind,
parent: None,
type_ref: None,
args: Vec::new(),
description: opt_str(t, "description"),
deprecated: None,
directives: Vec::new(),
});
match type_kind {
Kind::Object | Kind::Interface => {
let field_kind = roots.field_kind(&name);
for f in array(t, "fields") {
let fname = str_field(f, "name");
out.push(SchemaRecord {
path: format!("{name}.{fname}"),
name: fname,
kind: field_kind,
parent: Some(name.clone()),
type_ref: f.get("type").map(render_type),
args: args_of(f),
description: opt_str(f, "description"),
deprecated: deprecation(f),
directives: Vec::new(),
});
}
}
Kind::InputObject => {
for f in array(t, "inputFields") {
let fname = str_field(f, "name");
out.push(SchemaRecord {
path: format!("{name}.{fname}"),
name: fname,
kind: Kind::InputField,
parent: Some(name.clone()),
type_ref: f.get("type").map(render_type),
args: Vec::new(),
description: opt_str(f, "description"),
deprecated: deprecation(f),
directives: Vec::new(),
});
}
}
Kind::Enum => {
for v in array(t, "enumValues") {
let vname = str_field(v, "name");
out.push(SchemaRecord {
path: format!("{name}.{vname}"),
name: vname,
kind: Kind::EnumValue,
parent: Some(name.clone()),
type_ref: None,
args: Vec::new(),
description: opt_str(v, "description"),
deprecated: deprecation(v),
directives: Vec::new(),
});
}
}
_ => {}
}
}
fn render_type(t: &Value) -> String {
match t.get("kind").and_then(Value::as_str) {
Some("NON_NULL") => format!("{}!", t.get("ofType").map(render_type).unwrap_or_default()),
Some("LIST") => format!("[{}]", t.get("ofType").map(render_type).unwrap_or_default()),
_ => t
.get("name")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string(),
}
}
fn args_of(f: &Value) -> Vec<String> {
array(f, "args")
.iter()
.map(|a| {
let n = str_field(a, "name");
let ty = a.get("type").map(render_type).unwrap_or_default();
format!("{n}: {ty}")
})
.collect()
}
fn deprecation(v: &Value) -> Option<String> {
(v.get("isDeprecated").and_then(Value::as_bool) == Some(true))
.then(|| opt_str(v, "deprecationReason").unwrap_or_else(|| "deprecated".into()))
}
fn str_field(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}
fn opt_str(v: &Value, key: &str) -> Option<String> {
v.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn array<'a>(v: &'a Value, key: &str) -> &'a [Value] {
v.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn is_empty_array(v: &Value) -> bool {
v.as_array().is_some_and(|a| a.is_empty())
}
const INTROSPECTION_QUERY: &str = r#"
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types { ...FullType }
directives { name description args { ...InputValue } }
}
}
fragment FullType on __Type {
kind name description
fields(includeDeprecated: true) {
name description
args { ...InputValue }
type { ...TypeRef }
isDeprecated deprecationReason
}
inputFields { ...InputValue }
enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
}
fragment InputValue on __InputValue { name description type { ...TypeRef } }
fragment TypeRef on __Type {
kind name
ofType { kind name ofType { kind name ofType { kind name ofType { kind name
ofType { kind name ofType { kind name ofType { kind name } } } } } } }
}
"#;