use super::GraphQLGenerator;
use crate::codegen::common::case_conversion::to_snake_case;
use crate::codegen::graphql::sdl::SdlBuilder;
use crate::codegen::graphql::spec_parser::{GraphQLArgument, GraphQLField, GraphQLInputField, GraphQLSchema, TypeKind};
use anyhow::Result;
use heck::ToPascalCase;
use std::collections::HashSet;
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Default, Debug, Clone, Copy)]
pub struct ElixirGenerator;
impl GraphQLGenerator for ElixirGenerator {
fn generate_complete(&self, schema: &GraphQLSchema) -> Result<String> {
let mut code = header();
code.push_str(&render_types(schema));
code.push('\n');
code.push_str(&render_resolvers(schema));
code.push('\n');
code.push_str(&render_schema_module(schema));
Ok(format_elixir(&code))
}
fn generate_types(&self, schema: &GraphQLSchema) -> Result<String> {
let mut code = header();
code.push_str(&render_types(schema));
Ok(format_elixir(&code))
}
fn generate_resolvers(&self, schema: &GraphQLSchema) -> Result<String> {
let mut code = header();
code.push_str(&render_resolvers(schema));
Ok(format_elixir(&code))
}
fn generate_schema_definition(&self, schema: &GraphQLSchema) -> Result<String> {
let mut code = header();
code.push_str(&render_schema_module(schema));
Ok(format_elixir(&code))
}
}
fn header() -> String {
let mut code = String::new();
code.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
code.push_str("# This file was automatically generated from your GraphQL schema.\n");
code.push_str("# Any manual changes will be overwritten on the next generation.\n\n");
code
}
fn root_module_name() -> &'static str {
"GeneratedGraphQL"
}
fn render_types(schema: &GraphQLSchema) -> String {
let root = root_module_name();
let mut code = String::new();
for (type_name, type_def) in &schema.types {
if is_builtin_scalar(type_name) {
continue;
}
match type_def.kind {
TypeKind::Scalar => {
code.push_str(&format!(
r#"defmodule {root}.Scalars.{module_name} do
@moduledoc false
@type t :: String.t()
end
"#,
module_name = module_name(type_name)
));
}
TypeKind::Enum => {
let union = if type_def.enum_values.is_empty() {
"atom()".to_string()
} else {
type_def
.enum_values
.iter()
.map(|value| format!(":{}", value.name))
.collect::<Vec<_>>()
.join(" | ")
};
code.push_str(&format!(
r#"defmodule {root}.Enums.{module_name} do
@moduledoc false
@type t :: {union}
end
"#,
module_name = module_name(type_name)
));
}
TypeKind::Union => {
let union = if type_def.possible_types.is_empty() {
"map()".to_string()
} else {
type_def
.possible_types
.iter()
.map(|possible_type| named_type_spec(schema, possible_type, false, false, false))
.collect::<Vec<_>>()
.join(" | ")
};
code.push_str(&format!(
r#"defmodule {root}.Unions.{module_name} do
@moduledoc false
@type t :: {union}
end
"#,
module_name = module_name(type_name)
));
}
TypeKind::Object | TypeKind::Interface => {
let fields = type_def
.fields
.iter()
.map(|field| {
format!(
"{} => {}",
map_key(field.name.as_str()),
named_type_spec(
schema,
&field.type_name,
field.is_nullable,
field.is_list,
field.list_item_nullable
)
)
})
.collect::<Vec<_>>();
let body = if fields.is_empty() {
"%{}".to_string()
} else {
format!("%{{{}}}", fields.join(", "))
};
code.push_str(&format!(
r#"defmodule {root}.Types.{module_name} do
@moduledoc false
@type t :: {body}
end
"#,
module_name = module_name(type_name)
));
}
TypeKind::InputObject => {
let fields = type_def
.input_fields
.iter()
.map(|field| {
let key = if field.is_nullable {
format!("optional({})", map_key(field.name.as_str()))
} else {
format!("required({})", map_key(field.name.as_str()))
};
format!(
"{key} => {}",
named_type_spec(
schema,
&field.type_name,
field.is_nullable,
field.is_list,
field.list_item_nullable
)
)
})
.collect::<Vec<_>>();
let body = if fields.is_empty() {
"%{}".to_string()
} else {
format!("%{{{}}}", fields.join(", "))
};
code.push_str(&format!(
r#"defmodule {root}.Inputs.{module_name} do
@moduledoc false
@type t :: {body}
end
"#,
module_name = module_name(type_name)
));
}
TypeKind::List | TypeKind::NonNull => {}
}
}
code
}
fn render_resolvers(schema: &GraphQLSchema) -> String {
let root = root_module_name();
let mut code = String::new();
if !schema.queries.is_empty() {
code.push_str(&render_root_resolver_module(schema, root, "Query", &schema.queries));
}
if !schema.mutations.is_empty() {
code.push_str(&render_root_resolver_module(
schema,
root,
"Mutation",
&schema.mutations,
));
}
if !schema.subscriptions.is_empty() {
code.push_str(&render_root_resolver_module(
schema,
root,
"Subscription",
&schema.subscriptions,
));
}
code
}
fn render_root_resolver_module(
schema: &GraphQLSchema,
root: &str,
module_suffix: &str,
fields: &[GraphQLField],
) -> String {
let mut code = String::new();
code.push_str(&format!(
"defmodule {root}.Resolvers.{module_suffix} do\n @moduledoc false\n alias Spikard.Request\n\n"
));
code.push_str(" @type resolver_error :: %{required(:message) => String.t(), optional(:details) => term()}\n\n");
for field in fields {
let function_name = function_name(&field.name);
let args_type_name = format!("{}_{}_args", module_suffix.to_ascii_lowercase(), function_name);
let result_type_name = format!("{}_{}_result", module_suffix.to_ascii_lowercase(), function_name);
code.push_str(&format!(
" @type {args_type_name} :: {}\n",
inline_arguments_type(schema, &field.arguments)
));
code.push_str(&format!(
" @type {result_type_name} :: {}\n",
inline_type_spec(
schema,
&field.type_name,
field.is_nullable,
field.is_list,
field.list_item_nullable,
0,
false
)
));
code.push_str(&format!(
" @spec {function_name}({args_type_name}, Request.t() | nil) :: {{:ok, {result_type_name}}} | {{:error, resolver_error}}\n"
));
code.push_str(&format!(
" def {function_name}(args \\\\ %{{}}, _request \\\\ nil) do\n"
));
code.push_str(" _ = args\n");
code.push_str(" result = ");
code.push_str(&placeholder_for_field(schema, field, 2, &mut HashSet::new()));
code.push_str("\n {:ok, result}\n end\n\n");
}
code.push_str("end\n\n");
code
}
fn render_schema_module(schema: &GraphQLSchema) -> String {
let root = root_module_name();
let sdl = escape_pipe_delimiter(&SdlBuilder::new(schema).build());
let mut code = String::new();
code.push_str(&format!(
r#"defmodule {root} do
@moduledoc """
GraphQL router scaffolding generated from SDL.
This module accepts standard GraphQL-over-HTTP request payloads and dispatches
the root field to generated resolver modules. Generated scaffolding prefers
request variables for arguments and keeps SDL embedded for downstream tooling.
"""
use Spikard.Router
alias Spikard.Request
alias Spikard.Response
alias {root}.Resolvers
post("/graphql", &__MODULE__.handle_graphql/1)
@sdl ~S|{sdl}|
@query_fields {query_fields}
@mutation_fields {mutation_fields}
@subscription_fields {subscription_fields}
@type graphql_error :: %{{required(String.t()) => term()}}
@type graphql_response :: %{{required(String.t()) => term()}}
@spec sdl() :: String.t()
def sdl, do: @sdl
@spec handle_graphql(Request.t()) :: Response.t()
def handle_graphql(request) do
case Request.get_body(request) do
%{{"query" => query}} = payload when is_binary(query) ->
variables = normalize_variables(Map.get(payload, "variables"))
operation_name = Map.get(payload, "operationName")
case execute(query, variables, operation_name, request) do
{{:ok, data}} ->
Response.json(%{{"data" => data}})
{{:error, error}} ->
Response.json(%{{"errors" => [error]}}, status: 400)
end
_ ->
Response.json(%{{"errors" => [invalid_request_error()]}}, status: 400)
end
end
@spec execute(String.t(), map(), String.t() | nil, Request.t()) ::
{{:ok, graphql_response()}} | {{:error, graphql_error()}}
def execute(query, variables, operation_name, request) do
with {{:ok, operation_kind}} <- detect_operation_kind(query),
{{:ok, field_name}} <- detect_root_field(operation_kind, query, operation_name) do
dispatch(operation_kind, field_name, variables, request)
end
end
@spec detect_operation_kind(String.t()) :: {{:ok, :query | :mutation | :subscription}}
defp detect_operation_kind(query) do
normalized = String.trim_leading(query)
cond do
String.starts_with?(normalized, "mutation") -> {{:ok, :mutation}}
String.starts_with?(normalized, "subscription") -> {{:ok, :subscription}}
true -> {{:ok, :query}}
end
end
@spec detect_root_field(:query | :mutation | :subscription, String.t(), String.t() | nil) ::
{{:ok, String.t()}} | {{:error, graphql_error()}}
defp detect_root_field(kind, query, operation_name) do
fields = root_fields(kind)
case prefer_operation_name(fields, operation_name) || scan_query_for_field(fields, query) do
nil -> {{:error, unknown_operation_error(kind)}}
field_name -> {{:ok, field_name}}
end
end
@spec dispatch(:query | :mutation | :subscription, String.t(), map(), Request.t()) ::
{{:ok, graphql_response()}} | {{:error, graphql_error()}}
"#,
query_fields = render_string_list(&schema.queries),
mutation_fields = render_string_list(&schema.mutations),
subscription_fields = render_string_list(&schema.subscriptions)
));
code.push_str(" defp dispatch(kind, field_name, variables, request) do\n");
code.push_str(" case {kind, field_name} do\n");
for field in &schema.queries {
code.push_str(&dispatch_clause("query", "Query", field));
}
for field in &schema.mutations {
code.push_str(&dispatch_clause("mutation", "Mutation", field));
}
for field in &schema.subscriptions {
code.push_str(&dispatch_clause("subscription", "Subscription", field));
}
code.push_str(
r#" _ ->
{:error, unknown_operation_error(kind)}
end
end
@spec root_fields(:query | :mutation | :subscription) :: [String.t()]
defp root_fields(:query), do: @query_fields
defp root_fields(:mutation), do: @mutation_fields
defp root_fields(:subscription), do: @subscription_fields
@spec prefer_operation_name([String.t()], String.t() | nil) :: String.t() | nil
defp prefer_operation_name(fields, operation_name) when is_binary(operation_name) do
Enum.find(fields, &(&1 == operation_name))
end
defp prefer_operation_name(_fields, _operation_name), do: nil
@spec scan_query_for_field([String.t()], String.t()) :: String.t() | nil
defp scan_query_for_field(fields, query) do
Enum.find(fields, fn field_name ->
String.match?(query, ~r/\b#{Regex.escape(field_name)}\b/u)
end)
end
@spec normalize_variables(term()) :: map()
defp normalize_variables(variables) when is_map(variables), do: variables
defp normalize_variables(_variables), do: %{}
@spec normalize_args(map(), [String.t()]) :: map()
defp normalize_args(variables, keys) do
Enum.reduce(keys, %{}, fn key, acc ->
cond do
Map.has_key?(variables, key) ->
Map.put(acc, String.to_atom(key), Map.get(variables, key))
Map.has_key?(variables, String.to_atom(key)) ->
Map.put(acc, String.to_atom(key), Map.get(variables, String.to_atom(key)))
true ->
acc
end
end)
end
@spec invalid_request_error() :: graphql_error()
defp invalid_request_error do
%{"message" => "Invalid GraphQL request payload"}
end
@spec unknown_operation_error(:query | :mutation | :subscription) :: graphql_error()
defp unknown_operation_error(kind) do
%{"message" => "Unable to resolve #{kind} root field from GraphQL document"}
end
@spec resolver_error(String.t()) :: graphql_error()
defp resolver_error(message), do: %{"message" => message}
@spec resolver_error(String.t(), term()) :: graphql_error()
defp resolver_error(message, details) do
Map.put(resolver_error(message), "details", details)
end
@spec normalize_resolver_result(String.t(), term()) ::
{:ok, graphql_response()} | {:error, graphql_error()}
defp normalize_resolver_result(field_name, resolver_result) do
case resolver_result do
{:ok, result} ->
{:ok, %{field_name => result}}
{:error, %{message: message} = error} ->
{:error, resolver_error(message, Map.delete(error, :message))}
{:error, error} ->
{:error, resolver_error("Resolver failed", error)}
end
end
end
"#,
);
code
}
fn dispatch_clause(operation_kind: &str, module_suffix: &str, field: &GraphQLField) -> String {
let function_name = function_name(&field.name);
let field_name = &field.name;
let arg_keys = if field.arguments.is_empty() {
"[]".to_string()
} else {
field
.arguments
.iter()
.map(|argument| format!("\"{}\"", argument.name))
.collect::<Vec<_>>()
.join(", ")
};
format!(
" {{:{operation_kind}, \"{field_name}\"}} ->\n args = normalize_args(variables, [{arg_keys}])\n normalize_resolver_result(\"{field_name}\", Resolvers.{module_suffix}.{function_name}(args, request))\n",
)
}
fn render_string_list(fields: &[GraphQLField]) -> String {
let names = fields
.iter()
.map(|field| format!("\"{}\"", field.name))
.collect::<Vec<_>>();
format!("[{}]", names.join(", "))
}
fn function_name(name: &str) -> String {
to_snake_case(name)
}
fn module_name(name: &str) -> String {
name.to_pascal_case()
}
fn is_builtin_scalar(type_name: &str) -> bool {
matches!(type_name, "String" | "Int" | "Float" | "Boolean" | "ID")
}
fn map_key(name: &str) -> String {
if name.is_empty() || name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
"String.t()".to_string()
} else if name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
format!(":{name}")
} else {
"String.t()".to_string()
}
}
fn named_type_spec(
schema: &GraphQLSchema,
type_name: &str,
is_nullable: bool,
is_list: bool,
list_item_nullable: bool,
) -> String {
let root = root_module_name();
let base = match type_name {
"String" | "ID" => "String.t()".to_string(),
"Int" => "integer()".to_string(),
"Float" => "float()".to_string(),
"Boolean" => "boolean()".to_string(),
custom => match schema.types.get(custom).map(|type_def| type_def.kind) {
Some(TypeKind::Object | TypeKind::Interface) => {
format!("{root}.Types.{}.t()", module_name(custom))
}
Some(TypeKind::InputObject) => format!("{root}.Inputs.{}.t()", module_name(custom)),
Some(TypeKind::Enum) => format!("{root}.Enums.{}.t()", module_name(custom)),
Some(TypeKind::Union) => format!("{root}.Unions.{}.t()", module_name(custom)),
Some(TypeKind::Scalar) => format!("{root}.Scalars.{}.t()", module_name(custom)),
_ => "term()".to_string(),
},
};
wrap_type(base, is_nullable, is_list, list_item_nullable)
}
fn inline_arguments_type(schema: &GraphQLSchema, arguments: &[GraphQLArgument]) -> String {
if arguments.is_empty() {
return "%{}".to_string();
}
let entries = arguments
.iter()
.map(|argument| {
let key = if argument.is_nullable {
format!("optional({})", map_key(&argument.name))
} else {
format!("required({})", map_key(&argument.name))
};
let value = inline_type_spec(
schema,
&argument.type_name,
argument.is_nullable,
argument.is_list,
argument.list_item_nullable,
0,
true,
);
format!("{key} => {value}")
})
.collect::<Vec<_>>();
format!("%{{{}}}", entries.join(", "))
}
fn inline_type_spec(
schema: &GraphQLSchema,
type_name: &str,
is_nullable: bool,
is_list: bool,
list_item_nullable: bool,
depth: usize,
prefer_optional_keys: bool,
) -> String {
let base = inline_base_type_spec(schema, type_name, depth, prefer_optional_keys);
wrap_type(base, is_nullable, is_list, list_item_nullable)
}
fn inline_base_type_spec(schema: &GraphQLSchema, type_name: &str, depth: usize, prefer_optional_keys: bool) -> String {
if depth >= 2 {
return named_type_spec(schema, type_name, false, false, false);
}
match type_name {
"String" | "ID" => "String.t()".to_string(),
"Int" => "integer()".to_string(),
"Float" => "float()".to_string(),
"Boolean" => "boolean()".to_string(),
custom => match schema.types.get(custom) {
Some(type_def) => match type_def.kind {
TypeKind::Scalar => "String.t()".to_string(),
TypeKind::Enum => {
if type_def.enum_values.is_empty() {
"atom()".to_string()
} else {
type_def
.enum_values
.iter()
.map(|value| format!(":{}", value.name))
.collect::<Vec<_>>()
.join(" | ")
}
}
TypeKind::Object | TypeKind::Interface => {
if type_def.fields.is_empty() {
"%{}".to_string()
} else {
let entries = type_def
.fields
.iter()
.map(|field| {
format!(
"{} => {}",
map_key(&field.name),
inline_type_spec(
schema,
&field.type_name,
field.is_nullable,
field.is_list,
field.list_item_nullable,
depth + 1,
false
)
)
})
.collect::<Vec<_>>();
format!("%{{{}}}", entries.join(", "))
}
}
TypeKind::InputObject => {
if type_def.input_fields.is_empty() {
"%{}".to_string()
} else {
let entries = type_def
.input_fields
.iter()
.map(|field| {
let key = if prefer_optional_keys && field.is_nullable {
format!("optional({})", map_key(&field.name))
} else {
format!("required({})", map_key(&field.name))
};
format!(
"{key} => {}",
inline_type_spec(
schema,
&field.type_name,
field.is_nullable,
field.is_list,
field.list_item_nullable,
depth + 1,
true
)
)
})
.collect::<Vec<_>>();
format!("%{{{}}}", entries.join(", "))
}
}
TypeKind::Union => {
if type_def.possible_types.is_empty() {
"map()".to_string()
} else {
type_def
.possible_types
.iter()
.map(|possible_type| inline_base_type_spec(schema, possible_type, depth + 1, false))
.collect::<Vec<_>>()
.join(" | ")
}
}
TypeKind::List | TypeKind::NonNull => "term()".to_string(),
},
None => "term()".to_string(),
},
}
}
fn wrap_type(base: String, is_nullable: bool, is_list: bool, list_item_nullable: bool) -> String {
let with_list = if is_list {
let item_type = if list_item_nullable {
format!("{base} | nil")
} else {
base
};
format!("[{item_type}]")
} else {
base
};
if is_nullable {
format!("{with_list} | nil")
} else {
with_list
}
}
fn placeholder_for_field(
schema: &GraphQLSchema,
field: &GraphQLField,
indent_level: usize,
visited: &mut HashSet<String>,
) -> String {
placeholder_value(schema, &field.type_name, field.is_list, indent_level, visited)
}
fn placeholder_for_input(
schema: &GraphQLSchema,
field: &GraphQLInputField,
indent_level: usize,
visited: &mut HashSet<String>,
) -> String {
placeholder_value(schema, &field.type_name, field.is_list, indent_level, visited)
}
fn placeholder_value(
schema: &GraphQLSchema,
type_name: &str,
is_list: bool,
indent_level: usize,
visited: &mut HashSet<String>,
) -> String {
let base = if is_list {
let item = placeholder_value(schema, type_name, false, indent_level + 1, visited);
format!("[{item}]")
} else {
placeholder_scalar_or_object(schema, type_name, indent_level, visited)
};
base
}
fn placeholder_scalar_or_object(
schema: &GraphQLSchema,
type_name: &str,
indent_level: usize,
visited: &mut HashSet<String>,
) -> String {
match type_name {
"String" | "ID" => "\"TODO\"".to_string(),
"Int" => "0".to_string(),
"Float" => "0.0".to_string(),
"Boolean" => "false".to_string(),
custom => match schema.types.get(custom) {
Some(type_def) => match type_def.kind {
TypeKind::Scalar => "\"TODO\"".to_string(),
TypeKind::Enum => type_def
.enum_values
.first()
.map_or(":unknown".to_string(), |value| format!(":{}", value.name)),
TypeKind::Union => type_def
.possible_types
.first()
.map_or("%{}".to_string(), |possible_type| {
placeholder_scalar_or_object(schema, possible_type, indent_level, visited)
}),
TypeKind::Object | TypeKind::Interface => {
if !visited.insert(custom.to_string()) {
return "%{}".to_string();
}
let indent = " ".repeat(indent_level);
let child_indent = " ".repeat(indent_level + 1);
let rendered = type_def
.fields
.iter()
.map(|field| {
format!(
"{child_indent}{} => {}",
map_key(&field.name),
placeholder_for_field(schema, field, indent_level + 1, visited)
)
})
.collect::<Vec<_>>()
.join(",\n");
visited.remove(custom);
if rendered.is_empty() {
"%{}".to_string()
} else {
format!("%{{\n{rendered}\n{indent}}}")
}
}
TypeKind::InputObject => {
if !visited.insert(custom.to_string()) {
return "%{}".to_string();
}
let indent = " ".repeat(indent_level);
let child_indent = " ".repeat(indent_level + 1);
let rendered = type_def
.input_fields
.iter()
.map(|field| {
format!(
"{child_indent}{} => {}",
map_key(&field.name),
placeholder_for_input(schema, field, indent_level + 1, visited)
)
})
.collect::<Vec<_>>()
.join(",\n");
visited.remove(custom);
if rendered.is_empty() {
"%{}".to_string()
} else {
format!("%{{\n{rendered}\n{indent}}}")
}
}
TypeKind::List | TypeKind::NonNull => "%{}".to_string(),
},
None => "%{}".to_string(),
},
}
}
fn escape_pipe_delimiter(value: &str) -> String {
value.replace('|', "\\|")
}
fn format_elixir(code: &str) -> String {
let mut command = match Command::new("elixir")
.arg("-e")
.arg(
r#"input = IO.read(:stdio, :all)
IO.write(IO.iodata_to_binary(Code.format_string!(input, line_length: 120)))"#,
)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(command) => command,
Err(_) => return ensure_trailing_newline(code.to_string()),
};
let Some(stdin) = command.stdin.as_mut() else {
return ensure_trailing_newline(code.to_string());
};
if stdin.write_all(code.as_bytes()).is_err() {
return ensure_trailing_newline(code.to_string());
}
match command.wait_with_output() {
Ok(output) if output.status.success() => {
ensure_trailing_newline(String::from_utf8(output.stdout).unwrap_or_else(|_| code.to_string()))
}
_ => ensure_trailing_newline(code.to_string()),
}
}
fn ensure_trailing_newline(mut code: String) -> String {
if !code.ends_with('\n') {
code.push('\n');
}
code
}