use crate::functional_spec::Operation;
use crate::priorities_profile::Profile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub method: String,
pub path: String,
}
const CONV_NAMING: &str = "naming";
const CONV_ENTITY_TO_TABLE: &str = "entityToTable";
pub fn endpoint_for(op: &Operation, profile: &Profile) -> Endpoint {
Endpoint {
method: method_for_verb(&op.verb),
path: path_for(op, profile),
}
}
fn method_for_verb(verb: &str) -> String {
match verb.to_ascii_lowercase().as_str() {
"create" => "POST",
"read" | "list" | "get" => "GET",
"update" => "PUT",
"delete" => "DELETE",
_ => "POST", }
.to_string()
}
fn path_for(op: &Operation, profile: &Profile) -> String {
let base = match op.entity.as_deref() {
Some(entity) => format!("/api/v1/{}", table_name(entity, profile)),
None => "/api/v1".to_string(),
};
if is_bare_crud(op) {
base
} else {
format!("{base}/{}", to_kebab(&op.name))
}
}
fn is_bare_crud(op: &Operation) -> bool {
let name = op.name.to_ascii_lowercase();
let verb = op.verb.to_ascii_lowercase();
if name == verb {
return true;
}
if let Some(entity) = op.entity.as_deref() {
if name == format!("{verb}{}", entity.to_ascii_lowercase()) {
return true;
}
}
false
}
fn table_name(entity: &str, profile: &Profile) -> String {
let snake = match profile.conventions.get(CONV_NAMING).map(String::as_str) {
Some("snake_case") | None => to_snake(entity),
Some(_) => to_snake(entity), };
match profile
.conventions
.get(CONV_ENTITY_TO_TABLE)
.map(String::as_str)
{
Some("as-is") | Some("singular") => snake,
Some("pluralize") | None | Some(_) => pluralize(&snake),
}
}
fn to_snake(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch == '-' || ch == '_' || ch == ' ' {
if !out.ends_with('_') {
out.push('_');
}
} else if ch.is_ascii_uppercase() {
if i != 0 && !out.ends_with('_') {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
fn to_kebab(s: &str) -> String {
to_snake(s).replace('_', "-")
}
fn pluralize(snake: &str) -> String {
if snake.is_empty() {
return snake.to_string();
}
if snake.ends_with('s')
|| snake.ends_with('x')
|| snake.ends_with('z')
|| snake.ends_with("ch")
|| snake.ends_with("sh")
{
return format!("{snake}es");
}
if let Some(stem) = snake.strip_suffix('y') {
if !stem.ends_with(['a', 'e', 'i', 'o', 'u']) {
return format!("{stem}ies");
}
}
format!("{snake}s")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::functional_spec::{Operation, SpecProvenance};
use crate::priorities_profile::{ApiStyle, ArchitectureProfile, BackendProfile, Profile};
use std::collections::BTreeMap;
fn profile() -> Profile {
let mut conventions = BTreeMap::new();
conventions.insert("naming".into(), "snake_case".into());
conventions.insert("entityToTable".into(), "pluralize".into());
Profile {
profile_version: "0".into(),
backend: BackendProfile {
language: "python".into(),
framework: "fastapi".into(),
datastore: "postgres".into(),
api_style: ApiStyle::Rest,
auth: None,
},
architecture: ArchitectureProfile {
pattern: "layered".into(),
testing_bar: None,
min_coverage_pct: None,
},
conventions,
enforcement: None,
}
}
fn op(name: &str, verb: &str, entity: Option<&str>) -> Operation {
Operation {
name: name.into(),
verb: verb.into(),
entity: entity.map(String::from),
inputs: vec![],
effect: None,
confidence: SpecProvenance::Observed,
provenance: None,
credibility: None,
}
}
#[test]
fn named_custom_action_gets_kebab_subpath() {
let e = endpoint_for(&op("pairConfirm", "create", Some("Device")), &profile());
assert_eq!(e.method, "POST");
assert_eq!(e.path, "/api/v1/devices/pair-confirm");
}
#[test]
fn bare_crud_addresses_the_collection() {
let e = endpoint_for(&op("createInvoice", "create", Some("Invoice")), &profile());
assert_eq!(e.method, "POST");
assert_eq!(e.path, "/api/v1/invoices");
}
#[test]
fn verb_maps_to_method() {
assert_eq!(
endpoint_for(&op("read", "read", Some("Invoice")), &profile()).method,
"GET"
);
assert_eq!(
endpoint_for(&op("update", "update", Some("Invoice")), &profile()).method,
"PUT"
);
assert_eq!(
endpoint_for(&op("delete", "delete", Some("Invoice")), &profile()).method,
"DELETE"
);
}
#[test]
fn pluralization_and_casing() {
assert_eq!(pluralize("device"), "devices");
assert_eq!(pluralize("invoice"), "invoices");
assert_eq!(pluralize("category"), "categories");
assert_eq!(pluralize("address"), "addresses");
assert_eq!(to_snake("LineItem"), "line_item");
assert_eq!(to_kebab("pairConfirm"), "pair-confirm");
}
#[test]
fn multiword_entity_pluralizes_last_segment_style() {
let e = endpoint_for(
&op("createLineItem", "create", Some("LineItem")),
&profile(),
);
assert_eq!(e.path, "/api/v1/line_items");
}
#[test]
fn entity_to_table_as_is_skips_pluralization() {
let mut p = profile();
p.conventions.insert("entityToTable".into(), "as-is".into());
let e = endpoint_for(&op("createDevice", "create", Some("Device")), &p);
assert_eq!(e.path, "/api/v1/device");
}
#[test]
fn custom_op_without_entity_falls_back_to_api_v1() {
let e = endpoint_for(&op("healthCheck", "custom", None), &profile());
assert_eq!(e.path, "/api/v1/health-check");
assert_eq!(e.method, "POST");
}
}