This document explains the technical implementation of GraphQL exposure in this-rs, including the dynamic schema generation and custom executor.
The GraphQL implementation is completely modular and separate from the core framework:
src/server/exposure/
โโโ rest/
โ โโโ mod.rs # REST API exposure
โโโ graphql/
โโโ mod.rs # GraphQL exposure entry point
โโโ schema.rs # Legacy async-graphql schema (unused)
โโโ schema_generator.rs # Dynamic SDL schema generator
โโโ dynamic_schema.rs # Legacy dynamic schema (unused)
โโโ executor/
โโโ mod.rs # Executor module entry
โโโ core.rs # Main executor orchestration
โโโ query_executor.rs # Query resolution
โโโ mutation_executor.rs # CRUD mutations
โโโ link_mutations.rs # Link-specific mutations
โโโ field_resolver.rs # Field and relation resolution
โโโ utils.rs # Utility functions
Location: src/server/exposure/graphql/mod.rs
The entry point that builds the GraphQL router. Itโs completely separate from REST exposure.
pub struct GraphQLExposure;
impl GraphQLExposure {
pub fn build_router(host: Arc<ServerHost>) -> Result<Router> {
Router::new()
.route("/graphql", post(graphql_handler_custom))
.route("/graphql/playground", get(graphql_playground))
.route("/graphql/schema", get(graphql_dynamic_schema))
.layer(Extension(host))
}
}
Endpoints:
POST /graphql - GraphQL query/mutation endpointGET /graphql/playground - Interactive GraphQL playgroundGET /graphql/schema - SDL schema exportLocation: src/server/exposure/graphql/schema_generator.rs
Dynamically generates GraphQL SDL (Schema Definition Language) from:
ServerHostEntityFetcher::get_sample_entity() or list_as_json()links.yaml configurationKey Methods:
generate_sdl() - Orchestrates full schema generationgenerate_entity_type() - Generates type definition for an entitygenerate_query_root() - Generates Query type with all entity queriesgenerate_mutation_root() - Generates Mutation type with CRUD and link operationsget_relations_for() - Extracts relations for an entity from configExample Output:
type Order {
id: ID!
number: String!
customerName: String!
amount: Float!
invoices: [Invoice!]! # From links.yaml
}
type Query {
order(id: ID!): Order
orders(limit: Int, offset: Int): [Order!]!
}
type Mutation {
createOrder(data: JSON!): Order!
updateOrder(id: ID!, data: JSON!): Order!
deleteOrder(id: ID!): Boolean!
createInvoiceForOrder(parentId: ID!, data: JSON!): Invoice!
}
Location: src/server/exposure/graphql/executor/core.rs
A custom GraphQL executor that:
graphql-parserEntityFetcher and EntityCreatorLinkServiceWhy Custom?: async-graphql requires compile-time type definitions. Our dynamic schema requires runtime execution, so we implemented a custom executor.
Structure:
pub struct GraphQLExecutor {
host: Arc<ServerHost>,
schema_sdl: String, // Generated SDL (currently unused but stored)
}
impl GraphQLExecutor {
pub async fn execute(&self, query: &str, variables: Option<HashMap<String, Value>>) -> Result<Value>;
async fn execute_document(&self, doc: &Document, variables: HashMap<String, Value>) -> Result<Value>;
async fn execute_query(&self, selections: &[Selection], variables: &HashMap<String, Value>) -> Result<Value>;
async fn execute_mutation(&self, selections: &[Selection], variables: &HashMap<String, Value>) -> Result<Value>;
}
Location: src/server/exposure/graphql/executor/query_executor.rs
Resolves GraphQL queries:
orders, invoices): Calls EntityFetcher::list_as_json() with paginationorder, invoice): Calls EntityFetcher::fetch_as_json() with UUIDpub async fn resolve_query_field(
host: &Arc<ServerHost>,
field: &Field<'_, String>,
) -> Result<Value> {
// Check if plural query
if let Some(entity_type) = get_entity_type_from_plural(host, field_name) {
let entities = fetcher.list_as_json(limit, offset).await?;
// Resolve sub-fields...
}
// Check if singular query
if let Some(entity_type) = get_entity_type_from_singular(host, field_name) {
let entity = fetcher.fetch_as_json(&uuid).await?;
// Resolve sub-fields...
}
}
Location: src/server/exposure/graphql/executor/mutation_executor.rs
Handles all CRUD mutations:
create{Entity} - Calls EntityCreator::create_from_json()update{Entity} - Calls EntityCreator::update_from_json()delete{Entity} - Calls EntityCreator::delete()Dispatches to specialized modules for link mutations.
Location: src/server/exposure/graphql/executor/link_mutations.rs
Specialized mutations for link management:
createLink - Generic link creationdeleteLink - Generic link deletioncreate{Target}For{Source} - Create entity + link (e.g., createInvoiceForOrder)link{Target}To{Source} - Link existing entities (e.g., linkPaymentToInvoice)unlink{Target}From{Source} - Remove link (e.g., unlinkPaymentFromInvoice)Convention: Mutation names follow patterns:
create{Target}For{Source} โ Creates target, links to sourcelink{Source}To{Target} โ Links source to targetunlink{Source}From{Target} โ Removes link from source to targetLocation: src/server/exposure/graphql/executor/field_resolver.rs
Resolves entity fields and relations:
LinkService to find links, then EntityFetcher to resolve entitiesBoxFuture to handle nested selections recursivelyKey Functions:
pub async fn resolve_entity_fields(
host: &Arc<ServerHost>,
entity: Value,
selections: &[Selection],
entity_type: &str,
) -> Result<Value>
async fn resolve_relation_field_inner(
host: &Arc<ServerHost>,
entity: &serde_json::Map<String, Value>,
field: &Field,
entity_type: &str,
) -> Result<Option<Value>>
Relation Resolution Logic:
forward_route_name in links config โ Forward relationreverse_route_name in links config โ Reverse relationLinkService::find_by_source() or find_by_target() to get linksEntityFetcher::fetch_as_json()1. HTTP Request โ POST /graphql
โ
2. graphql_handler_custom() โ Creates GraphQLExecutor
โ
3. GraphQLExecutor::execute()
โ Parse query with graphql-parser
โ
4. execute_document() โ Detect operation type
โ
5. execute_query() โ For each field
โ
6. query_executor::resolve_query_field()
โ Identify entity type (plural/singular)
โ
7. EntityFetcher::list_as_json() or fetch_as_json()
โ
8. field_resolver::resolve_entity_fields()
โ For each selection
โ
9a. Direct field โ Extract from JSON
9b. Relation field โ resolve_relation_field_inner()
โ
10. LinkService::find_by_source() / find_by_target()
โ
11. EntityFetcher::fetch_as_json() for each linked entity
โ
12. Recursive resolve_entity_fields() for nested selections
โ
13. Return resolved JSON
1. HTTP Request โ POST /graphql (mutation)
โ
2. GraphQLExecutor::execute()
โ
3. execute_mutation() โ For each field
โ
4. mutation_executor::resolve_mutation_field()
โ Dispatch by mutation name pattern
โ
5a. CRUD mutation โ mutation_executor::create/update/delete_entity_mutation()
โ EntityCreator::create_from_json() / update_from_json() / delete()
5b. Link mutation โ link_mutations::*_mutation()
โ LinkService::create() / delete()
โ LinkService::find_by_source() (for unlink)
6. field_resolver::resolve_entity_fields() โ Resolve sub-selections
โ
7. Return resolved entity/link
Problem: async-graphql requires compile-time type definitions. Our entities are defined at runtime.
Solution: Custom executor that:
graphql-parserTrade-offs:
Decision: Use JSON! scalar for mutation data argument instead of strongly-typed input types.
Rationale:
Trade-offs:
Decision: Split executor into 6 modules (core, query, mutation, links, fields, utils).
Rationale:
executor.rs was 751 linesStructure:
core.rs (~122 lines) - Orchestrationquery_executor.rs (~96 lines) - Query resolutionmutation_executor.rs (~149 lines) - CRUD mutationslink_mutations.rs (~241 lines) - Link operationsfield_resolver.rs (~177 lines) - Field/relation resolutionutils.rs (~132 lines) - UtilitiesSchemaGenerator::generate_query_root()query_executor.rsSchemaGenerator::generate_mutation_root()mutation_executor.rs or link_mutations.rsfield_resolver.rs with new resolution logicresolve_entity_fields_impl() to handle new field typesCurrent: Schema is generated on each request to /graphql/schema.
Future Optimization: Cache generated SDL and invalidate when entities change.
Current: Executor created per request.
Future Optimization: Cache executor instance (schema doesnโt change at runtime).
Current: Sequential fetching of related entities.
Future Optimization: Batch fetching using DataLoader pattern.
Current: Recursive resolution may fetch same entity multiple times.
Future Optimization: Add query depth limit and entity fetching cache.
Each executor module can be tested independently:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_pluralize() {
assert_eq!(utils::pluralize("order"), "orders");
assert_eq!(utils::pluralize("company"), "companies");
}
#[tokio::test]
async fn test_query_resolution() {
let host = create_test_host();
let field = parse_query_field("orders");
let result = query_executor::resolve_query_field(&host, &field).await?;
// Assert result...
}
}
@deprecated, @skip, @includeschema.rs and dynamic_schema.rs are unused (can be removed)The GraphQL implementation is:
Key Innovation: Custom executor that enables truly dynamic GraphQL without sacrificing type safety or developer experience.