use fraiseql_core::schema::MutationOperation;
use serde_json::{Map, Value, json};
use super::{
OpenApiGenerator,
format::{capitalize, extract_action, method_to_string, to_snake},
};
use crate::routes::rest::resource::{HttpMethod, RestResource, RestRoute, RouteSource};
impl OpenApiGenerator<'_> {
pub(super) fn build_paths(&self) -> Value {
let mut paths = Map::new();
let openapi_path = "/openapi.json";
let mut meta_get = json!({
"summary": "OpenAPI specification",
"description": "Returns this OpenAPI 3.0.3 specification as JSON.",
"tags": ["Meta"],
"responses": {
"200": {
"description": "OpenAPI specification",
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
}
}
});
self.apply_security(&mut meta_get);
paths.insert(openapi_path.to_string(), json!({ "get": meta_get }));
for resource in &self.route_table.resources {
for route in &resource.routes {
if !self.mounted.contains(&route.path, route.method) {
continue;
}
let path_key = &route.path;
let method_key = method_to_string(route.method);
let operation = self.build_operation(resource, route);
let path_obj =
paths.entry(path_key.clone()).or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(ref mut map) = path_obj {
map.insert(method_key.to_string(), operation);
}
}
self.add_bulk_operations(&mut paths, resource);
self.add_stream_endpoint(&mut paths, resource);
}
Value::Object(paths)
}
pub(super) fn build_operation(&self, resource: &RestResource, route: &RestRoute) -> Value {
let mut op = Map::new();
op.insert("tags".to_string(), json!([capitalize(&resource.name)]));
let (summary, operation_id) = self.operation_summary(resource, route);
op.insert("summary".to_string(), json!(summary));
op.insert("operationId".to_string(), json!(operation_id));
if self.is_deprecated(route) {
op.insert("deprecated".to_string(), json!(true));
}
let params = self.build_parameters(resource, route);
if !params.is_empty() {
op.insert("parameters".to_string(), Value::Array(params));
}
if let Some(body) = self.build_request_body(resource, route) {
op.insert("requestBody".to_string(), body);
}
op.insert("responses".to_string(), self.build_responses(resource, route));
let mut operation = Value::Object(op);
self.apply_security(&mut operation);
operation
}
pub(super) fn operation_summary(
&self,
resource: &RestResource,
route: &RestRoute,
) -> (String, String) {
let res_name = &resource.name;
let type_name = &resource.type_name;
match (&route.source, route.method) {
(RouteSource::Query { name }, HttpMethod::Get) => {
let is_list = self
.schema
.queries
.iter()
.find(|q| q.name == *name)
.is_some_and(|q| q.returns_list);
if is_list {
(format!("List {res_name}"), format!("list_{res_name}"))
} else {
(format!("Get {type_name} by ID"), format!("get_{}", to_snake(type_name)))
}
},
(RouteSource::Mutation { name }, HttpMethod::Post) => {
let mutation = self.schema.mutations.iter().find(|m| m.name == *name);
if let Some(MutationOperation::Insert { .. }) = mutation.map(|m| &m.operation) {
(format!("Create {type_name}"), format!("create_{}", to_snake(type_name)))
} else {
let action = extract_action(name, type_name);
(format!("{} {type_name}", capitalize(&action)), name.clone())
}
},
(RouteSource::Mutation { name: _ }, HttpMethod::Put) => {
(format!("Replace {type_name}"), format!("replace_{}", to_snake(type_name)))
},
(RouteSource::Mutation { name }, HttpMethod::Patch) => {
if route.path.contains('/') && route.path.matches('/').count() > 1 {
let action = extract_action(name, type_name);
(format!("{} {type_name}", capitalize(&action)), name.clone())
} else {
(format!("Update {type_name}"), format!("update_{}", to_snake(type_name)))
}
},
(RouteSource::Mutation { .. }, HttpMethod::Delete) => {
(format!("Delete {type_name}"), format!("delete_{}", to_snake(type_name)))
},
_ => ("Operation".to_string(), "operation".to_string()),
}
}
pub(super) fn is_deprecated(&self, route: &RestRoute) -> bool {
match &route.source {
RouteSource::Query { name } => self
.schema
.queries
.iter()
.find(|q| q.name == *name)
.is_some_and(|q| q.deprecation.is_some()),
RouteSource::Mutation { name } => self
.schema
.mutations
.iter()
.find(|m| m.name == *name)
.is_some_and(|m| m.deprecation.is_some()),
}
}
pub(super) fn add_stream_endpoint(
&self,
paths: &mut Map<String, Value>,
resource: &RestResource,
) {
let stream_path = format!("/{}/stream", resource.name);
if !self.mounted.contains(&stream_path, HttpMethod::Get) {
return;
}
let mut responses = json!({
"200": {
"description": "SSE event stream. Each event's `id:` is the Change-Spine \
sequence (`seq`) of the change, and is absent for a change \
whose source row carried no sequence — per the SSE \
specification an absent `id:` leaves the client's \
last-event-id unchanged.",
"content": {
"text/event-stream": {
"schema": { "type": "string" }
}
}
},
"400": {
"description": "Bad Request — `Last-Event-ID` is not an id this stream \
issues. Every event carries `id: <seq>`, the Change-Spine \
sequence, so a resume point is an integer \
(`RESUME_POINT_INVALID`)."
},
"410": {
"description": "Gone — the event named by `Last-Event-ID` is no longer in \
the change log, or was issued by another stream, so what \
followed it cannot be established \
(`RESUME_POINT_UNKNOWN`)."
},
"413": {
"description": "Content Too Large — the resume point is further behind \
than `[rest].sse_max_replay_events` allows replaying. \
Refused before the first frame rather than served in part \
(`RESUME_TOO_FAR_BEHIND`)."
},
"501": {
"description": "Not Implemented — the `observers` feature is disabled, no \
event transport is configured, or the request carried a \
`Last-Event-ID` in a deployment that keeps no record of \
what this stream delivered, so there is no delivery order \
to resume from (`RESUMPTION_UNSUPPORTED`)."
}
});
if self.schema.is_multi_tenant() {
responses["403"] = json!({
"description": "Forbidden — this deployment is multi-tenant and the request \
carries no tenant, so the stream cannot be scoped to one."
});
}
let mut stream_get = json!({
"tags": [capitalize(&resource.name)],
"summary": format!("Stream {} changes (SSE)", resource.name),
"operationId": format!("stream_{}", resource.name),
"description": format!(
"Subscribe to real-time changes on {} via Server-Sent Events. \
Requires the `observers` feature. Events: `insert`, `update`, `delete`, `ping` (heartbeat). \
In a multi-tenant deployment the subscription is scoped to the caller's tenant. \
Reconnecting with `Last-Event-ID` resumes from where the previous connection stopped.",
resource.name
),
"parameters": [
{
"name": "Accept",
"in": "header",
"required": true,
"schema": { "type": "string", "enum": ["text/event-stream"] },
"description": "Must be text/event-stream for SSE."
},
{
"name": "Last-Event-ID",
"in": "header",
"required": false,
"schema": { "type": "string" },
"description": "Sent automatically by a browser EventSource on reconnect. \
The stream replays every event delivered after the one this \
id names, in the order they were delivered, and then continues \
live. Delivery is at-least-once: an event may be repeated \
across a reconnect, and `(object_type, seq)` is the dedup key. \
A resume that cannot be honoured is refused (400, 410, 413 or \
501) rather than answered with a stream that silently skips \
everything since the given id."
}
],
"responses": responses
});
self.apply_security(&mut stream_get);
paths.insert(stream_path, json!({ "get": stream_get }));
}
}