From c7836180ecf973b0ce71dca13cb5740140f5cc49 Mon Sep 17 00:00:00 2001 From: Joel Alvarez Date: Mon, 27 Jul 2026 10:06:34 -0700 Subject: [PATCH] feat: model api contracts --- src/contracts.rs | 312 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 6 +- 2 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/contracts.rs diff --git a/src/contracts.rs b/src/contracts.rs new file mode 100644 index 0000000..2f6cb7c --- /dev/null +++ b/src/contracts.rs @@ -0,0 +1,312 @@ +//! Vocabulario mínimo para expresar un contrato público de API. +//! +//! El módulo no construye un servidor ni serializa datos. Su propósito es +//! volver discutible qué promete una operación y qué detalles permanecen +//! internos antes de elegir REST, GraphQL o gRPC. + +use std::fmt; + +/// Describe si un campo siempre debe estar presente para el consumidor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presence { + /// El consumidor puede depender de que el campo esté presente. + Required, + /// El contrato permite omitir el campo de manera explícita. + Optional, +} + +/// Un dato que cruza la frontera pública de la API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicField { + name: String, + meaning: String, + presence: Presence, +} + +impl PublicField { + /// Crea un campo público con un nombre y significado verificables. + pub fn new( + name: impl Into, + meaning: impl Into, + presence: Presence, + ) -> Result { + let name = required_text(name.into(), "nombre del campo")?; + let meaning = required_text(meaning.into(), "significado del campo")?; + + Ok(Self { + name, + meaning, + presence, + }) + } + + /// Nombre estable que el consumidor usa para identificar el dato. + pub fn name(&self) -> &str { + &self.name + } + + /// Semántica que evita que el consumidor deduzca el dato desde ejemplos. + pub fn meaning(&self) -> &str { + &self.meaning + } + + /// Regla de presencia observable para el consumidor. + pub fn presence(&self) -> Presence { + self.presence + } +} + +/// Un error que el consumidor puede distinguir y usar para tomar una acción. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionableError { + code: String, + consumer_action: String, +} + +impl ActionableError { + /// Crea una declaración de error con una acción concreta para el consumidor. + pub fn new( + code: impl Into, + consumer_action: impl Into, + ) -> Result { + let code = required_text(code.into(), "código de error")?; + let consumer_action = required_text(consumer_action.into(), "acción del consumidor")?; + + Ok(Self { + code, + consumer_action, + }) + } + + /// Código público y estable del error. + pub fn code(&self) -> &str { + &self.code + } + + /// Acción que el consumidor puede tomar después de reconocer el error. + pub fn consumer_action(&self) -> &str { + &self.consumer_action + } +} + +/// Detalle deliberadamente excluido de la promesa pública. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivateDetail { + description: String, +} + +impl PrivateDetail { + /// Declara una decisión que el proveedor puede cambiar sin romper el contrato. + pub fn new(description: impl Into) -> Result { + Ok(Self { + description: required_text(description.into(), "detalle privado")?, + }) + } + + /// Explicación del límite que no pertenece a la API pública. + pub fn description(&self) -> &str { + &self.description + } +} + +/// Promesa observable de una operación de API. +/// +/// ``` +/// use rust_api_design::contracts::{ +/// ActionableError, ApiContract, Presence, PrivateDetail, PublicField, +/// }; +/// +/// let contract = ApiContract::new( +/// "confirmar un pedido", +/// vec![PublicField::new("pedido_id", "identificador del pedido", Presence::Required)?], +/// vec![PublicField::new("estado", "estado confirmado del pedido", Presence::Required)?], +/// vec![ActionableError::new("pedido_no_encontrado", "corregir el identificador")?], +/// vec![PrivateDetail::new("la tabla donde se almacena el pedido")?], +/// )?; +/// +/// assert_eq!(contract.capability(), "confirmar un pedido"); +/// assert_eq!(contract.outputs().len(), 1); +/// # Ok::<(), rust_api_design::contracts::ContractError>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiContract { + capability: String, + inputs: Vec, + outputs: Vec, + errors: Vec, + private_details: Vec, +} + +impl ApiContract { + /// Construye un contrato que tiene una capacidad, resultado y límites claros. + pub fn new( + capability: impl Into, + inputs: Vec, + outputs: Vec, + errors: Vec, + private_details: Vec, + ) -> Result { + let capability = required_text(capability.into(), "capacidad")?; + + if outputs.is_empty() { + return Err(ContractError::MissingOutput); + } + + ensure_unique_field_names(&inputs, &outputs)?; + + Ok(Self { + capability, + inputs, + outputs, + errors, + private_details, + }) + } + + /// Tarea que el consumidor puede solicitar, sin revelar almacenamiento. + pub fn capability(&self) -> &str { + &self.capability + } + + /// Datos públicos que la operación acepta. + pub fn inputs(&self) -> &[PublicField] { + &self.inputs + } + + /// Datos públicos que la operación promete devolver. + pub fn outputs(&self) -> &[PublicField] { + &self.outputs + } + + /// Errores que el consumidor puede distinguir de forma accionable. + pub fn errors(&self) -> &[ActionableError] { + &self.errors + } + + /// Decisiones del proveedor que no forman parte de la promesa pública. + pub fn private_details(&self) -> &[PrivateDetail] { + &self.private_details + } +} + +/// Errores que impiden declarar una promesa pública suficientemente clara. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractError { + /// Falta texto en una parte que un consumidor debe poder entender. + EmptyText { subject: &'static str }, + /// Una operación sin salida no explica qué resultado promete. + MissingOutput, + /// Un mismo nombre público aparece en más de una parte de la frontera. + DuplicateField { name: String }, +} + +impl fmt::Display for ContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyText { subject } => write!(formatter, "El {subject} no puede estar vacío."), + Self::MissingOutput => { + write!(formatter, "Un contrato debe declarar al menos una salida.") + } + Self::DuplicateField { name } => { + write!( + formatter, + "El campo público `{name}` está declarado más de una vez." + ) + } + } + } +} + +impl std::error::Error for ContractError {} + +fn required_text(value: String, subject: &'static str) -> Result { + let value = value.trim().to_owned(); + + if value.is_empty() { + return Err(ContractError::EmptyText { subject }); + } + + Ok(value) +} + +fn ensure_unique_field_names( + inputs: &[PublicField], + outputs: &[PublicField], +) -> Result<(), ContractError> { + let mut names = std::collections::BTreeSet::new(); + + for field in inputs.iter().chain(outputs) { + if !names.insert(field.name()) { + return Err(ContractError::DuplicateField { + name: field.name().to_owned(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + ActionableError, ApiContract, ContractError, Presence, PrivateDetail, PublicField, + }; + + fn field(name: &str) -> PublicField { + PublicField::new(name, "significado público", Presence::Required).unwrap() + } + + #[test] + fn builds_a_contract_with_observable_promise_and_private_limit() { + let contract = ApiContract::new( + "consultar un pedido", + vec![field("pedido_id")], + vec![field("estado")], + vec![ + ActionableError::new("pedido_no_encontrado", "corregir el identificador").unwrap(), + ], + vec![PrivateDetail::new("la tabla de almacenamiento").unwrap()], + ) + .unwrap(); + + assert_eq!(contract.capability(), "consultar un pedido"); + assert_eq!(contract.errors()[0].code(), "pedido_no_encontrado"); + assert_eq!( + contract.private_details()[0].description(), + "la tabla de almacenamiento" + ); + } + + #[test] + fn rejects_a_contract_without_an_observable_output() { + let error = ApiContract::new( + "consultar un pedido", + vec![field("pedido_id")], + vec![], + vec![], + vec![], + ) + .unwrap_err(); + + assert_eq!(error, ContractError::MissingOutput); + } + + #[test] + fn rejects_duplicate_names_across_the_public_boundary() { + let error = ApiContract::new( + "actualizar un pedido", + vec![field("estado")], + vec![field("estado")], + vec![], + vec![], + ) + .unwrap_err(); + + assert_eq!( + error, + ContractError::DuplicateField { + name: "estado".to_owned(), + } + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index aff4efd..80bd73e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ //! Modelos educativos para el curso `rust-api-design`. //! -//! El crate inicia deliberadamente vacío: cada módulo público aparece cuando -//! su capítulo tenga explicación, issue, pruebas y revisión humana diferida. +//! Cada módulo público aparece cuando su capítulo tenga explicación, issue, +//! pruebas y revisión humana diferida. + +pub mod contracts;