use apiplant_abi::{FunctionAccess, HttpMethod};
use apiplant_core::schema::{Access, Field, FieldType};
use apiplant_core::{App, Resource};
use serde_json::{json, Map, Value};
use crate::functions::FunctionRegistry;
pub fn build(app: &App, functions: &FunctionRegistry) -> Value {
let base = &app.config.server.base_path;
let server_url = if base.is_empty() { "/" } else { base.as_str() };
let mut paths = Map::new();
let mut schemas = Map::new();
for r in app.resources.values() {
schemas.insert(read_schema_name(r), resource_read_schema(r));
schemas.insert(input_schema_name(r), resource_input_schema(r));
paths.insert(format!("/{}", r.meta.name), collection_path(r));
paths.insert(format!("/{}/{{id}}", r.meta.name), item_path(r));
}
for parent in app.resources.values() {
for child in app.resources.values() {
let related: Vec<_> = child
.references()
.into_iter()
.filter(|rf| rf.target == parent.meta.name)
.collect();
if related.is_empty() {
continue;
}
paths.insert(
format!("/{}/{{id}}/{}", parent.meta.name, child.meta.name),
nested_path(parent, child, &related),
);
}
}
paths.insert("/auth/register".into(), auth_register_path());
paths.insert("/auth/login".into(), auth_login_path(app));
paths.insert("/auth/me".into(), auth_me_path());
paths.insert("/auth/apikeys".into(), auth_apikeys_path());
for f in functions.iter() {
let m = &f.manifest;
let access = m.access();
if access == FunctionAccess::Private {
continue;
}
let input_ref = ingest_fn_schema(
&mut schemas,
m.name.as_str(),
"Input",
m.input_schema.as_str(),
);
let output_ref = ingest_fn_schema(
&mut schemas,
m.name.as_str(),
"Output",
m.output_schema.as_str(),
);
paths.insert(
format!("/functions/{}", m.name),
function_path(
m.method,
&access,
m.name.as_str(),
m.description.as_str(),
input_ref,
output_ref,
),
);
}
json!({
"openapi": "3.0.3",
"info": {
"title": app.docs_title(),
"version": env!("CARGO_PKG_VERSION"),
"description": "API generated by apiplant from resource, auth and function definitions.",
},
"servers": [{ "url": server_url }],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "A session token from POST /auth/login or /auth/register.",
},
"apiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-Api-Key",
"description": "An API key from POST /auth/apikeys. Acts as its owning user.",
},
},
"schemas": schemas,
},
"paths": paths,
})
}
fn read_schema_name(r: &Resource) -> String {
pascal(&r.meta.name)
}
fn input_schema_name(r: &Resource) -> String {
format!("{}Input", pascal(&r.meta.name))
}
fn field_schema(f: &Field) -> Value {
let mut base = match f.ty {
FieldType::String | FieldType::Text => json!({ "type": "string" }),
FieldType::Integer | FieldType::BigInt => json!({ "type": "integer" }),
FieldType::Float => json!({ "type": "number" }),
FieldType::Boolean => json!({ "type": "boolean" }),
FieldType::Uuid | FieldType::Reference => json!({ "type": "string", "format": "uuid" }),
FieldType::Timestamp => json!({ "type": "string", "format": "date-time" }),
FieldType::Json => json!({}),
};
if let (Some(max), Value::Object(map)) = (f.max_length, &mut base) {
map.insert("maxLength".into(), json!(max));
}
base
}
fn resource_read_schema(r: &Resource) -> Value {
let mut props = Map::new();
props.insert(
"id".into(),
json!({ "type": "string", "format": "uuid", "readOnly": true }),
);
for (name, field) in &r.fields {
if field.hidden {
continue;
}
props.insert(name.clone(), field_schema(field));
}
if r.meta.timestamps {
props.insert(
"created_at".into(),
json!({ "type": "string", "format": "date-time", "readOnly": true }),
);
props.insert(
"updated_at".into(),
json!({ "type": "string", "format": "date-time", "readOnly": true }),
);
}
json!({ "type": "object", "properties": props })
}
fn resource_input_schema(r: &Resource) -> Value {
let mut props = Map::new();
let mut required = Vec::new();
for (name, field) in &r.fields {
if field.hidden || name == &r.meta.owner_field || name == "organization_id" {
continue;
}
props.insert(name.clone(), field_schema(field));
if field.required {
required.push(json!(name));
}
}
let mut obj = json!({ "type": "object", "properties": props });
if !required.is_empty() {
obj["required"] = json!(required);
}
obj
}
fn security_for(access: &Access) -> Option<Value> {
match access {
Access::Public => None,
_ => Some(json!([{ "bearerAuth": [] }, { "apiKeyAuth": [] }])),
}
}
fn access_note(access: &Access) -> String {
match access {
Access::Public => "Public — no authentication required.".into(),
Access::Authenticated => "Requires authentication.".into(),
Access::Member => "Requires membership of the active organisation.".into(),
Access::Owner => "Requires authentication; scoped to records you own.".into(),
Access::Role(role) => format!("Requires the `{role}` role in the active organisation."),
Access::Private => "Not exposed.".into(),
}
}
fn with_security(mut op: Value, access: &Access) -> Value {
if let Some(sec) = security_for(access) {
op["security"] = sec;
}
op
}
fn json_body(schema_ref: Value) -> Value {
json!({ "required": true, "content": { "application/json": { "schema": schema_ref } } })
}
fn ref_to(name: &str) -> Value {
json!({ "$ref": format!("#/components/schemas/{name}") })
}
fn collection_path(r: &Resource) -> Value {
let name = &r.meta.name;
let read_ref = read_schema_name(r);
let input_ref = input_schema_name(r);
let mut path = Map::new();
if r.permissions.list != Access::Private {
let op = with_security(
json!({
"tags": [name],
"operationId": format!("list_{name}"),
"summary": format!("List {name}"),
"description": access_note(&r.permissions.list),
"parameters": list_parameters(r),
"responses": {
"200": {
"description": "A page of records",
"content": { "application/json": {
"schema": { "type": "array", "items": ref_to(&read_ref) }
} }
}
}
}),
&r.permissions.list,
);
path.insert("get".into(), op);
}
if r.permissions.create != Access::Private {
let op = with_security(
json!({
"tags": [name],
"operationId": format!("create_{name}"),
"summary": format!("Create {name}"),
"description": access_note(&r.permissions.create),
"requestBody": json_body(ref_to(&input_ref)),
"responses": {
"201": { "description": "Created", "content": { "application/json": { "schema": ref_to(&read_ref) } } },
"400": { "description": "Invalid input" },
"401": { "description": "Authentication required" },
}
}),
&r.permissions.create,
);
path.insert("post".into(), op);
}
Value::Object(path)
}
fn item_path(r: &Resource) -> Value {
let name = &r.meta.name;
let read_ref = read_schema_name(r);
let input_ref = input_schema_name(r);
let id_param = json!([{
"name": "id", "in": "path", "required": true,
"schema": { "type": "string", "format": "uuid" }
}]);
let mut path = Map::new();
path.insert("parameters".into(), id_param);
if r.permissions.read != Access::Private {
path.insert(
"get".into(),
with_security(
json!({
"tags": [name],
"operationId": format!("get_{name}"),
"summary": format!("Fetch a {name} by id"),
"description": access_note(&r.permissions.read),
"parameters": [expand_parameter(r)],
"responses": {
"200": { "description": "The record", "content": { "application/json": { "schema": ref_to(&read_ref) } } },
"404": { "description": "Not found" },
}
}),
&r.permissions.read,
),
);
}
if r.permissions.update != Access::Private {
let update_op = with_security(
json!({
"tags": [name],
"operationId": format!("update_{name}"),
"summary": format!("Update a {name}"),
"description": access_note(&r.permissions.update),
"requestBody": json_body(ref_to(&input_ref)),
"responses": {
"200": { "description": "Updated", "content": { "application/json": { "schema": ref_to(&read_ref) } } },
"404": { "description": "Not found" },
}
}),
&r.permissions.update,
);
path.insert("patch".into(), update_op.clone());
path.insert("put".into(), update_op);
}
if r.permissions.delete != Access::Private {
path.insert(
"delete".into(),
with_security(
json!({
"tags": [name],
"operationId": format!("delete_{name}"),
"summary": format!("Delete a {name}"),
"description": access_note(&r.permissions.delete),
"responses": {
"204": { "description": "Deleted" },
"404": { "description": "Not found" },
}
}),
&r.permissions.delete,
),
);
}
Value::Object(path)
}
fn relation_names(r: &Resource) -> Vec<String> {
r.references().into_iter().map(|rf| rf.relation).collect()
}
fn expand_parameter(r: &Resource) -> Value {
let rels = relation_names(r);
let desc = if rels.is_empty() {
"Comma-separated relations to inline (this resource has no references).".to_string()
} else {
format!(
"Comma-separated relations to inline. Available: {}.",
rels.join(", ")
)
};
json!({
"name": "expand", "in": "query", "required": false,
"schema": { "type": "string" }, "description": desc,
})
}
fn list_parameters(r: &Resource) -> Value {
let mut params = vec![
json!({ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 500 } }),
json!({ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }),
expand_parameter(r),
];
for (name, field) in &r.fields {
if field.hidden {
continue;
}
params.push(json!({
"name": name, "in": "query", "required": false,
"schema": field_schema(field),
"description": format!("Filter by exact `{name}`."),
}));
}
Value::Array(params)
}
fn nested_path(parent: &Resource, child: &Resource, related: &[apiplant_core::Reference]) -> Value {
let child_name = &child.meta.name;
let parent_name = &parent.meta.name;
let via_note = if related.len() > 1 {
let fields = related
.iter()
.map(|rf| format!("`{}`", rf.field))
.collect::<Vec<_>>()
.join(", ");
format!(" `{child_name}` references `{parent_name}` via {fields}; add `?via=<field>` to disambiguate.")
} else {
String::new()
};
let op = with_security(
json!({
"tags": [child_name],
"operationId": format!("list_{child_name}_by_{parent_name}"),
"summary": format!("List {child_name} belonging to a {parent_name}"),
"description": format!("{}{}", access_note(&child.permissions.list), via_note),
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 500 } },
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } },
],
"responses": {
"200": {
"description": format!("A page of {child_name}"),
"content": { "application/json": { "schema": { "type": "array", "items": ref_to(&read_schema_name(child)) } } }
}
}
}),
&child.permissions.list,
);
json!({ "get": op })
}
fn token_response() -> Value {
json!({
"type": "object",
"properties": { "token": { "type": "string" } }
})
}
fn auth_register_path() -> Value {
json!({
"post": {
"tags": ["auth"],
"operationId": "register",
"summary": "Register a new user",
"description": "Creates a user and returns a session token. Requires a `password`; other properties map to the user resource's fields.",
"requestBody": json_body(json!({
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "format": "password" },
},
"required": ["password"],
"additionalProperties": true,
})),
"responses": {
"201": { "description": "Created", "content": { "application/json": { "schema": token_response() } } },
"403": { "description": "Registration disabled" },
}
}
})
}
fn auth_login_path(app: &App) -> Value {
let identity = app
.resources
.get("user")
.and_then(|r| r.auth.as_ref())
.map(|a| a.identity_field.clone())
.unwrap_or_else(|| "email".to_string());
let identity_key = identity.clone();
json!({
"post": {
"tags": ["auth"],
"operationId": "login",
"summary": "Log in",
"description": "Exchanges credentials for a session token. Paste the returned token into **Authorize → bearerAuth**.",
"requestBody": json_body(json!({
"type": "object",
"properties": {
identity_key: { "type": "string" },
"password": { "type": "string", "format": "password" },
},
"required": [identity, "password"],
})),
"responses": {
"200": { "description": "Authenticated", "content": { "application/json": { "schema": token_response() } } },
"401": { "description": "Invalid credentials" },
}
}
})
}
fn auth_me_path() -> Value {
json!({
"get": {
"tags": ["auth"],
"operationId": "me",
"summary": "Check the current credential",
"description": "Verifies the caller's token or API key and that the account it names still exists. Returns 401 if either is no longer true.",
"security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
"responses": {
"200": {
"description": "Credential is valid",
"content": { "application/json": { "schema": json!({
"type": "object",
"properties": { "user_id": { "type": "string", "format": "uuid" } },
}) } }
},
"401": { "description": "Invalid credential, or the user no longer exists" },
}
}
})
}
fn auth_apikeys_path() -> Value {
json!({
"post": {
"tags": ["auth"],
"operationId": "createApiKey",
"summary": "Issue an API key",
"description": "Creates an API key for the authenticated caller. The plaintext key is returned once — use it via the `X-Api-Key` header (Authorize → apiKeyAuth).",
"security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
"requestBody": json_body(json!({
"type": "object",
"properties": { "name": { "type": "string" } },
})),
"responses": {
"201": {
"description": "Key created",
"content": { "application/json": { "schema": json!({
"type": "object",
"properties": {
"api_key": { "type": "string" },
"id": { "type": "string", "format": "uuid" },
}
}) } }
},
"401": { "description": "Authentication required" },
}
}
})
}
#[allow(clippy::too_many_arguments)]
fn function_path(
method: HttpMethod,
access: &FunctionAccess,
name: &str,
description: &str,
input_ref: Option<String>,
output_ref: Option<String>,
) -> Value {
let verb = match method {
HttpMethod::Get => "get",
HttpMethod::Post => "post",
HttpMethod::Put => "put",
HttpMethod::Delete => "delete",
};
let note = match access {
FunctionAccess::Public => "Public — no authentication required.".to_string(),
FunctionAccess::Authenticated => "Requires authentication.".to_string(),
FunctionAccess::Member => "Requires membership of the active organization.".to_string(),
FunctionAccess::Role(role) => {
format!("Requires the `{role}` role in the active organization.")
}
FunctionAccess::Private => "Not exposed.".to_string(),
};
let untyped = || json!({ "type": "object" });
let response_schema = output_ref.map(|r| ref_to(&r)).unwrap_or_else(untyped);
let mut op = json!({
"tags": ["functions"],
"operationId": format!("fn_{name}"),
"summary": if description.is_empty() { format!("Invoke {name}") } else { description.to_string() },
"description": note,
"responses": {
"200": { "description": "Function result", "content": { "application/json": { "schema": response_schema } } },
"400": { "description": "Invalid input" },
}
});
if matches!(method, HttpMethod::Post | HttpMethod::Put) {
let request_schema = input_ref.map(|r| ref_to(&r)).unwrap_or_else(untyped);
op["requestBody"] = json_body(request_schema);
}
if !access.is_public() {
op["security"] = json!([{ "bearerAuth": [] }, { "apiKeyAuth": [] }]);
}
json!({ verb: op })
}
fn ingest_fn_schema(
schemas: &mut Map<String, Value>,
func: &str,
kind: &str,
raw: &str,
) -> Option<String> {
if raw.trim().is_empty() {
return None;
}
let mut root: Value = serde_json::from_str(raw).ok()?;
let component = format!("Fn{}{}", pascal(func), kind);
let prefix = format!("Fn{}_", pascal(func));
if let Some(obj) = root.as_object_mut() {
for defs_key in ["$defs", "definitions"] {
if let Some(Value::Object(defs)) = obj.remove(defs_key) {
for (def_name, mut def) in defs {
rewrite_refs(&mut def, &prefix);
schemas.insert(format!("{prefix}{def_name}"), def);
}
}
}
obj.remove("$schema");
obj.remove("title");
}
rewrite_refs(&mut root, &prefix);
schemas.insert(component.clone(), root);
Some(component)
}
fn rewrite_refs(value: &mut Value, prefix: &str) {
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get_mut("$ref") {
for p in ["#/$defs/", "#/definitions/"] {
if let Some(rest) = r.strip_prefix(p) {
*r = format!("#/components/schemas/{prefix}{rest}");
break;
}
}
}
for v in map.values_mut() {
rewrite_refs(v, prefix);
}
}
Value::Array(arr) => {
for v in arr {
rewrite_refs(v, prefix);
}
}
_ => {}
}
}
pub fn swagger_ui_html(spec_url: &str, title: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<style>body {{ margin: 0; }}</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({{
url: {spec_url},
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
}});
</script>
</body>
</html>"#,
title = html_escape(title),
spec_url = serde_json::to_string(spec_url).unwrap_or_else(|_| "\"openapi.json\"".into()),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn pascal(s: &str) -> String {
s.split('_')
.filter(|p| !p.is_empty())
.map(|p| {
let mut c = p.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_app_dir(label: &str) -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
dir.push(format!(
"apiplant-openapi-{label}-{}-{stamp}",
std::process::id()
));
fs::create_dir_all(dir.join("models")).unwrap();
dir
}
#[test]
fn input_schema_excludes_hidden_owner_and_organization_fields() {
let resource: Resource = toml::from_str(
r#"
[resource]
name = "post"
[fields.title]
type = "string"
required = true
[fields.owner_id]
type = "reference"
references = "user"
required = true
[fields.organization_id]
type = "reference"
references = "organization"
required = true
[fields.secret]
type = "string"
hidden = true
"#,
)
.unwrap();
let schema = resource_input_schema(&resource);
let props = schema.get("properties").unwrap().as_object().unwrap();
assert!(props.contains_key("title"));
assert!(!props.contains_key("owner_id"));
assert!(!props.contains_key("organization_id"));
assert!(!props.contains_key("secret"));
assert_eq!(schema["required"], json!(["title"]));
}
#[test]
fn build_emits_nested_paths_auth_routes_and_security() {
let dir = temp_app_dir("build");
fs::write(
dir.join("main.toml"),
r#"
[server]
base_path = "/api"
[docs]
title = "Test API"
"#,
)
.unwrap();
fs::write(
dir.join("models/post.toml"),
r#"
[resource]
name = "post"
[permissions]
list = "member"
read = "member"
create = "member"
update = "owner"
delete = "role:admin"
[fields.title]
type = "string"
required = true
[fields.owner_id]
type = "reference"
references = "user"
required = true
"#,
)
.unwrap();
fs::write(
dir.join("models/comment.toml"),
r#"
[resource]
name = "comment"
[fields.body]
type = "text"
required = true
[fields.post_id]
type = "reference"
references = "post"
required = true
"#,
)
.unwrap();
fs::write(
dir.join("models/plan.toml"),
r#"
[resource]
name = "plan"
scope = "global"
[permissions]
list = "public"
read = "public"
create = "private"
update = "private"
delete = "private"
[fields.name]
type = "string"
"#,
)
.unwrap();
let app = App::load(&dir).unwrap();
let spec = build(&app, &FunctionRegistry::default());
assert_eq!(spec["info"]["title"], "Test API");
assert_eq!(spec["servers"][0]["url"], "/api");
assert!(spec["paths"]["/post"].get("get").is_some());
assert!(spec["paths"]["/post/{id}/comment"].get("get").is_some());
assert!(spec["paths"]["/auth/register"].get("post").is_some());
assert!(spec["components"]["securitySchemes"]["bearerAuth"].is_object());
assert!(spec["paths"]["/post"]["get"]["security"].is_array());
assert!(spec["paths"]["/plan"]["get"].get("security").is_none());
assert_eq!(
spec["paths"]["/post/{id}"]["delete"]["description"],
"Requires the `admin` role in the active organisation."
);
fs::remove_dir_all(dir).unwrap();
}
}