pub mod derivation;
pub mod naming;
pub mod validation;
#[cfg(test)]
mod tests;
use std::{
collections::{BTreeSet, HashMap},
fmt,
};
use derivation::derive_resource;
use fraiseql_core::schema::{CompiledSchema, MutationDefinition, QueryDefinition};
use tracing::debug;
use validation::{detect_conflicts, is_filtered_out, should_skip_query};
#[derive(Debug, Default, Clone)]
pub struct MountedRoutes(BTreeSet<(String, HttpMethod)>);
impl MountedRoutes {
#[must_use]
pub fn read_surface(route_table: &RestRouteTable) -> Self {
let mut mounted = Self::default();
for resource in &route_table.resources {
for route in &resource.routes {
if route.method == HttpMethod::Get {
mounted.insert(route.path.clone(), HttpMethod::Get);
}
}
mounted.insert(stream_route_path(resource), HttpMethod::Get);
}
mounted
}
#[must_use]
pub fn write_surface(schema: &CompiledSchema, route_table: &RestRouteTable) -> Self {
use fraiseql_core::schema::MutationOperation;
let mut mounted = Self::default();
for resource in &route_table.resources {
for route in &resource.routes {
mounted.insert(route.path.clone(), route.method);
}
let collection = collection_path(resource);
let has = |pred: fn(&MutationOperation) -> bool| {
resource.routes.iter().any(|r| {
matches!(&r.source, RouteSource::Mutation { name }
if schema.find_mutation(name).is_some_and(|m| pred(&m.operation)))
})
};
if has(|op| matches!(op, MutationOperation::Update { .. }))
&& !mounted.contains(&collection, HttpMethod::Patch)
{
mounted.insert(collection.clone(), HttpMethod::Patch);
}
if has(|op| matches!(op, MutationOperation::Delete { .. }))
&& !mounted.contains(&collection, HttpMethod::Delete)
{
mounted.insert(collection, HttpMethod::Delete);
}
mounted.insert(stream_route_path(resource), HttpMethod::Get);
}
mounted
}
pub fn insert(&mut self, path: impl Into<String>, method: HttpMethod) {
self.0.insert((path.into(), method));
}
#[must_use]
pub fn contains(&self, path: &str, method: HttpMethod) -> bool {
self.0.iter().any(|(p, m)| p == path && *m == method)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, HttpMethod)> {
self.0.iter().map(|(p, m)| (p.as_str(), *m))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[must_use]
pub fn collection_path(resource: &RestResource) -> String {
format!("/{}", resource.name)
}
#[must_use]
pub fn stream_route_path(resource: &RestResource) -> String {
format!("/{}/stream", resource.name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Get => write!(f, "GET"),
Self::Post => write!(f, "POST"),
Self::Put => write!(f, "PUT"),
Self::Patch => write!(f, "PATCH"),
Self::Delete => write!(f, "DELETE"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UpdateCoverage {
Full,
Partial,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RouteSource {
Query {
name: String,
},
Mutation {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestRoute {
pub method: HttpMethod,
pub path: String,
pub source: RouteSource,
pub update_coverage: Option<UpdateCoverage>,
pub success_status: u16,
}
#[derive(Debug, Clone)]
pub struct RestResource {
pub name: String,
pub type_name: String,
pub id_arg: Option<String>,
pub routes: Vec<RestRoute>,
}
#[derive(Debug, Clone)]
pub struct RestRouteTable {
pub base_path: String,
pub resources: Vec<RestResource>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub level: DiagnosticLevel,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DiagnosticLevel {
Info,
Warning,
Error,
}
impl RestRouteTable {
pub fn from_compiled_schema(schema: &CompiledSchema) -> Result<Self, String> {
let config = schema.rest_config.clone().unwrap_or_default();
let base_path = config.path.clone();
let mut query_groups: HashMap<&str, Vec<&QueryDefinition>> = HashMap::new();
let mut mutation_groups: HashMap<&str, Vec<&MutationDefinition>> = HashMap::new();
let mut skipped_function_backed: Vec<&str> = Vec::new();
for q in &schema.queries {
if should_skip_query(q) {
debug!(query = %q.name, "skipping query (aggregate/window/scalar)");
continue;
}
if q.function.is_some() {
skipped_function_backed.push(q.name.as_str());
continue;
}
if is_filtered_out(&q.name, &config) {
debug!(query = %q.name, "skipping query (include/exclude filter)");
continue;
}
if schema.find_type(&q.return_type).is_none() {
debug!(query = %q.name, return_type = %q.return_type, "skipping query (no TypeDefinition)");
continue;
}
query_groups.entry(q.return_type.as_str()).or_default().push(q);
}
for m in &schema.mutations {
if is_filtered_out(&m.name, &config) {
debug!(mutation = %m.name, "skipping mutation (include/exclude filter)");
continue;
}
if schema.find_type(&m.return_type).is_none() {
debug!(mutation = %m.name, return_type = %m.return_type, "skipping mutation (no TypeDefinition)");
continue;
}
mutation_groups.entry(m.return_type.as_str()).or_default().push(m);
}
let mut all_types: Vec<&str> = query_groups.keys().copied().collect();
for t in mutation_groups.keys() {
if !all_types.contains(t) {
all_types.push(t);
}
}
all_types.sort_unstable();
let mut resources = Vec::new();
let mut diagnostics = Vec::new();
if !skipped_function_backed.is_empty() {
diagnostics.push(Diagnostic {
level: DiagnosticLevel::Info,
message: format!(
"Function-backed queries are not exposed over REST (#1329): {}. They are \
GraphQL root fields — the derived REST surface invents filters and \
pagination from the type, and a function-backed field accepts neither. \
Query them at /graphql.",
skipped_function_backed.join(", ")
),
});
}
for type_name in all_types {
let Some(type_def) = schema.find_type(type_name) else {
continue;
};
let queries = query_groups.get(type_name).map_or(&[][..], |v| v.as_slice());
let mutations = mutation_groups.get(type_name).map_or(&[][..], |v| v.as_slice());
let resource =
derive_resource(type_name, type_def, queries, mutations, &config, &mut diagnostics);
if let Some(r) = resource {
resources.push(r);
}
}
detect_conflicts(&resources, &mut diagnostics)?;
let table = Self {
base_path,
resources,
diagnostics,
};
Ok(table)
}
}
impl fmt::Display for RestRouteTable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "REST Route Table (base: {})", self.base_path)?;
for resource in &self.resources {
writeln!(f, " Resource: {} (type: {})", resource.name, resource.type_name)?;
for route in &resource.routes {
writeln!(f, " {} {}{}", route.method, self.base_path, route.path)?;
}
}
Ok(())
}
}