extern crate rustc_ast;
extern crate rustc_hir;
use rustc_hir::{Expr, ExprKind};
use rustc_lint::{LateContext, LateLintPass, LintContext};
dylint_linting::declare_late_lint! {
#[doc = include_str!("../../docs/de08_rest_api_conventions/de0801_api_endpoint_version/README.md")]
pub DE0801_API_ENDPOINT_VERSION,
Deny,
"API endpoints must follow /{service-name}/v{N}/{resource} format (DE0801)"
}
impl<'tcx> LateLintPass<'tcx> for De0801ApiEndpointVersion {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Call(func, args) = &expr.kind
&& let ExprKind::Path(qpath) = &func.kind
{
let is_operation_builder_http_method = match qpath {
rustc_hir::QPath::TypeRelative(ty, segment) => {
let method_name = segment.ident.name.as_str();
let is_http_method = HTTP_METHODS.contains(&method_name);
if is_http_method {
type_contains_operation_builder(ty)
} else {
false
}
}
rustc_hir::QPath::Resolved(_, path) => {
let segments: Vec<&str> = path
.segments
.iter()
.map(|seg| seg.ident.name.as_str())
.collect();
if segments.len() >= 2 {
let has_op_builder = segments.contains(&"OperationBuilder");
let last_is_http_method = segments
.last()
.map(|s| HTTP_METHODS.contains(s))
.unwrap_or(false);
has_op_builder && last_is_http_method
} else {
false
}
}
};
if is_operation_builder_http_method && let Some(path_arg) = args.first() {
check_path_argument(cx, path_arg);
}
}
}
}
#[derive(Debug, PartialEq)]
enum PathValidationError {
MissingServiceName,
InvalidServiceName(String),
MissingVersion,
InvalidVersionFormat(String),
MissingResource,
InvalidResourceName(String),
}
fn is_valid_kebab_case(segment: &str) -> bool {
if segment.is_empty() {
return false;
}
if segment.starts_with('-') || segment.ends_with('-') {
return false;
}
segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_valid_version(segment: &str) -> bool {
if !segment.starts_with('v') {
return false;
}
let after_v = &segment[1..];
if after_v.is_empty() {
return false;
}
after_v.chars().all(|c| c.is_ascii_digit())
}
fn is_path_param(segment: &str) -> bool {
segment.starts_with('{') && segment.ends_with('}')
}
fn validate_api_path(path: &str) -> Result<(), PathValidationError> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return Err(PathValidationError::MissingServiceName);
}
let service_name = segments[0];
if is_valid_version(service_name) {
return Err(PathValidationError::MissingServiceName);
}
if !is_valid_kebab_case(service_name) {
return Err(PathValidationError::InvalidServiceName(
service_name.to_string(),
));
}
if segments.len() < 2 {
return Err(PathValidationError::MissingVersion);
}
let version = segments[1];
if !is_valid_version(version) {
return Err(PathValidationError::InvalidVersionFormat(
version.to_string(),
));
}
if segments.len() < 3 {
return Err(PathValidationError::MissingResource);
}
for segment in &segments[2..] {
if is_path_param(segment) {
continue;
}
if !is_valid_kebab_case(segment) {
return Err(PathValidationError::InvalidResourceName(
(*segment).to_string(),
));
}
}
Ok(())
}
const HTTP_METHODS: &[&str] = &["get", "post", "put", "delete", "patch"];
fn type_contains_operation_builder(ty: &rustc_hir::Ty<'_>) -> bool {
match &ty.kind {
rustc_hir::TyKind::Path(qpath) => match qpath {
rustc_hir::QPath::Resolved(_, path) => path
.segments
.iter()
.any(|seg| seg.ident.name.as_str() == "OperationBuilder"),
rustc_hir::QPath::TypeRelative(inner_ty, segment) => {
segment.ident.name.as_str() == "OperationBuilder"
|| type_contains_operation_builder(inner_ty)
}
},
_ => false,
}
}
fn check_path_argument<'tcx>(cx: &LateContext<'tcx>, path_arg: &'tcx Expr<'tcx>) {
if let ExprKind::Lit(lit) = &path_arg.kind
&& let rustc_ast::ast::LitKind::Str(sym, _) = lit.node
{
let path = sym.as_str();
if let Err(err) = validate_api_path(path) {
let (message, help, note) = match err {
PathValidationError::MissingServiceName => (
format!(
"API endpoint `{}` is missing a service name before version (DE0801)",
path
),
"use format: /{service-name}/v{N}/{resource}".to_string(),
"service name must come before version segment".to_string(),
),
PathValidationError::InvalidServiceName(name) => (
format!(
"API endpoint `{}` has invalid service name `{}` (DE0801)",
path, name
),
"service name must be kebab-case (lowercase letters, numbers, dashes)"
.to_string(),
"service name must not start or end with a dash".to_string(),
),
PathValidationError::MissingVersion => (
format!(
"API endpoint `{}` is missing a version segment (DE0801)",
path
),
"add version as second segment: /{service-name}/v{N}/{resource}".to_string(),
"version must be v1, v2, etc.".to_string(),
),
PathValidationError::InvalidVersionFormat(ver) => (
format!(
"API endpoint `{}` has invalid version format `{}` (DE0801)",
path, ver
),
"version must be lowercase 'v' followed by digits (v1, v2, v10)".to_string(),
"semver (v1.0) and uppercase (V1) are not allowed".to_string(),
),
PathValidationError::MissingResource => (
format!(
"API endpoint `{}` is missing a resource after version (DE0801)",
path
),
"add resource: /{service-name}/v{N}/{resource}".to_string(),
"at least one resource segment is required after version".to_string(),
),
PathValidationError::InvalidResourceName(name) => (
format!(
"API endpoint `{}` has invalid resource name `{}` (DE0801)",
path, name
),
"resource names must be kebab-case (lowercase letters, numbers, dashes)"
.to_string(),
"resource names must not start or end with a dash".to_string(),
),
};
cx.span_lint(DE0801_API_ENDPOINT_VERSION, path_arg.span, |diag| {
diag.primary_message(message);
diag.help(help);
diag.note(note);
});
}
}
}